Compare commits

...

243 Commits

Author SHA1 Message Date
Jeremy Stretch
bf44e512ff Post-release version bump 2016-07-14 15:22:14 -04:00
Jeremy Stretch
026403ed38 Release v1.2.2 2016-07-14 15:21:22 -04:00
Jeremy Stretch
f6bd1f0c48 Make the HA warning re: SECRET_KEY a note 2016-07-14 14:03:57 -04:00
Jeremy Stretch
66489438b9 Merge pull request #298 from rekkoner/develop
Updated SECRET_KEY instructions for HA installs. Issue 295
2016-07-14 14:01:06 -04:00
Jeremy Stretch
e5a6a4f05e Fixes #174: Added search and site filter to provider list 2016-07-14 13:53:30 -04:00
brandon whitehead
9e4aa9c056 Updated SECRET_KEY instructions for HA installs. Issue 295 2016-07-14 12:33:21 -05:00
Jeremy Stretch
4ce40891f0 Prettified device type view 2016-07-14 12:39:55 -04:00
Jeremy Stretch
46b1ac23af Allow for setting mgmt_only=True in "Add management interfaces" link 2016-07-14 11:39:53 -04:00
Jeremy Stretch
a5f6e64849 Fixes #290: Added mgmt interfaces table to device type view 2016-07-14 11:30:15 -04:00
Jeremy Stretch
b9db1ac7f7 Merge pull request #283 from ercpe/html-overflow
Use overflow-y: scoll on html element
2016-07-13 16:03:53 -04:00
Jeremy Stretch
124c2acad7 Merge pull request #287 from bellwood/ui-add-glyphicons-to-panel-headers
Add 'filter' glyphicon to filter panel header
2016-07-13 16:02:15 -04:00
bellwood
2691590aa1 Add 'search' glyphicon to filter panel header 2016-07-13 15:36:26 -04:00
bellwood
51cc0d5083 Add 'search' glyphicon to filter panel header 2016-07-13 15:36:07 -04:00
bellwood
9c32943d73 Add 'search' glyphicon to filter panel header 2016-07-13 15:35:41 -04:00
bellwood
4483ba55dd Add 'search' glyphicon to filter panel header 2016-07-13 15:34:23 -04:00
bellwood
f20e0edb35 Add 'search' glyphicon to filter panel header 2016-07-13 15:33:52 -04:00
bellwood
aed2180142 Add 'search' glyphicon to filter panel header 2016-07-13 15:32:39 -04:00
Jeremy Stretch
4913d25d18 Fixes #268: Added support for full 32-bit ASN space 2016-07-13 15:30:15 -04:00
bellwood
9e181c20c7 Add 'filter' glyphicon to filter panel header 2016-07-13 15:26:24 -04:00
Jeremy Stretch
404d934736 Removed redundant template context processor 2016-07-13 14:08:46 -04:00
Jeremy Stretch
024c7da15b Fixes #115: Fix deprecated django.core.context_processors reference 2016-07-13 14:05:21 -04:00
Jeremy Stretch
d3a5b82d93 Fixes #282: De-select "all" checkbox if one or more objects are deselected 2016-07-13 13:50:50 -04:00
Jeremy Stretch
1e3a03c463 Merge branch 'develop' of github.com:digitalocean/netbox into develop 2016-07-13 13:08:17 -04:00
Jeremy Stretch
bafbc052e2 Fixes #270: Add rack group filter for devices 2016-07-13 13:07:55 -04:00
Jeremy Stretch
9421ec040c Fixes #271: Add rack group filter for devices 2016-07-13 13:07:02 -04:00
Jeremy Stretch
07fc2e5502 Merge pull request #273 from bellwood/devices-filter-add-rackgroup
allow filtering by rack group
2016-07-13 12:55:11 -04:00
Jeremy Stretch
9098001bcb Post-release version bump 2016-07-13 12:11:10 -04:00
Jeremy Stretch
d9bf199e75 Version bump: v1.2.1 2016-07-13 12:01:34 -04:00
Jeremy Stretch
6f1ed9fc16 Clarified the process of voting on feature requests 2016-07-13 11:47:20 -04:00
Jeremy Stretch
96b496ffa8 Updated documentation to include banner settings 2016-07-13 11:24:34 -04:00
Jeremy Stretch
f1b6f0cfee Fixes #285: Added PREFER_IPV4 configuration setting 2016-07-13 11:16:09 -04:00
Jeremy Stretch
e19ce043d6 Fixes #275: Exclude self when checking for overlapping aggregates 2016-07-13 10:37:25 -04:00
Johann Schmitz
35a2671525 Use overflow-y: scoll on html element to avoid jumping around when the previous/next page adds a vertical scrollbar. 2016-07-13 15:39:59 +02:00
Jeremy Stretch
03542b400d Renamed IPAMConfig to DCIMConfig 2016-07-13 09:25:13 -04:00
Jeremy Stretch
73d24532c9 Merge pull request #281 from lukerussell/Link-to-docs-in-readme
Added a link to docs in readme.md
2016-07-13 09:04:00 -04:00
Jeremy Stretch
b60f964835 Fixes #272: Added a step to copy the gunicorn config 2016-07-12 16:57:00 -04:00
lukerussell
8e7e02a622 Added a link to docs in readme.md
Adding a direct link for easy access. I couldn't find a link anywhere except digging in through the docs/ directory.
2016-07-13 06:04:29 +10:00
Jeremy Stretch
2c23ca33a2 Fixes #274: Correct reference to old field 2016-07-12 15:48:56 -04:00
bellwood
69affb7a6e fixed "rack group" filter label for/dcim/racks/ 2016-07-12 15:16:32 -04:00
bellwood
6a6cf14a38 Update forms.py
added label
2016-07-12 15:12:36 -04:00
Jeremy Stretch
e1da3b8f10 Related to #243: Implemented natsort on all Device and DeviceType objects (except interfaces) 2016-07-12 14:53:59 -04:00
bellwood
da50cd0f03 allow filtering by rack group
adds the ability to filter devices by rack group
2016-07-12 14:42:47 -04:00
Jeremy Stretch
d80ffd2308 Merge pull request #248 from Zanthras/develop
possible fix for #243 generic sorting for device bays
2016-07-12 14:13:25 -04:00
Jeremy Stretch
18846cf40a Fixes #271: Corrected select_related() in secrets API 2016-07-12 12:27:26 -04:00
Jeremy Stretch
e81a2094df Post-release version bump 2016-07-12 11:40:40 -04:00
Jeremy Stretch
17011843d7 Version bump: v1.2.0 2016-07-12 11:28:28 -04:00
Jeremy Stretch
4c45e38aea Fixes #162: Return Unicode from display_name() 2016-07-12 10:46:32 -04:00
Jeremy Stretch
e1a6188580 Merge pull request #261 from digitalocean/primary-ip4-ip6
Initial work on #93: Primary IPv4/IPv6 support
2016-07-12 10:38:00 -04:00
Jeremy Stretch
4e4bb01a55 Initial work on #93: Primary IPv4/IPv6 support 2016-07-11 16:24:46 -04:00
Jeremy Stretch
e92f60afda Fixes #260: Corrected typo in get_queryset() 2016-07-11 16:21:05 -04:00
Jeremy Stretch
a365cfcf9b Corrected removal of div 2016-07-11 15:04:39 -04:00
Jeremy Stretch
f617828712 A little cleanup from #222 2016-07-11 13:29:58 -04:00
Jeremy Stretch
d85561c6fd Merge pull request #222 from Gelob/motd
Banner support
2016-07-11 13:12:49 -04:00
Jeremy Stretch
4cba418d89 Merge pull request #256 from srossen/patch-1
Update netbox.md
2016-07-11 11:43:27 -04:00
Jeremy Stretch
6112e5542d Merge pull request #251 from bellwood/mobile-fix-navbar-stacking
fixes #217 - navbar not collapsing soon enough
2016-07-11 11:42:05 -04:00
Jeremy Stretch
1b8786ab98 Merge pull request #255 from digitalocean/docker-install-docs
Fixes #246 - Add build directive to docker-compose.yml
2016-07-11 10:00:46 -04:00
Steve Rossen
876361c0b2 Update netbox.md
Added libpq-dev as a dependency so the pip install of requirements.txt will complete.
2016-07-11 08:55:21 -05:00
Zach Moody
db25894363 Fixes #246 - Add build directive to docker-compose.yml 2016-07-10 19:00:40 -05:00
bellwood
f1881fad71 Update base.css 2016-07-09 14:09:35 -04:00
Jeremy Stretch
40173b4f8e Added "What NetBox isn't" section do intro doc 2016-07-09 11:51:08 -04:00
Joel
173a4cde8b Update the requirements file to include the natsort library requirement. 2016-07-08 23:27:00 -07:00
Joel
d9867423de Use the natsort library to provide a generic sorting option for better sorting of generic names for device bays. 2016-07-08 22:47:08 -07:00
Jeremy Stretch
bf35b4121f Merge pull request #240 from linuxsimba/bug_215
Potential quick fix for Rack view broken when viewport less than 992px wide
2016-07-08 23:00:21 -04:00
Jeremy Stretch
85461f9be0 Merge pull request #245 from bellwood/mobile-fix-for-footer
fixes #219 - footer display issue on mobile
2016-07-08 22:59:44 -04:00
Jeremy Stretch
fafd2ab517 Merge pull request #244 from bellwood/mobile-fix-login-form-width
fixes #218 - login form width on mobile
2016-07-08 22:59:00 -04:00
Jeremy Stretch
498eb50f56 Fixes PEP8 E401 CI error 2016-07-08 22:56:54 -04:00
bellwood
1cdf70da0b Update _base.html 2016-07-08 20:45:27 -04:00
bellwood
cbd6370889 Update login.html 2016-07-08 20:43:58 -04:00
Jeremy Stretch
8dd6112a4b Corrected detection of private_key 2016-07-08 17:49:20 -04:00
Jeremy Stretch
4c354277ec Enabled LDAP authentication 2016-07-08 17:09:35 -04:00
Jeremy Stretch
b8c5366c3e Split configuration doc into two sections 2016-07-08 16:25:34 -04:00
Jeremy Stretch
129415e15f Remove obsolete docs.html template 2016-07-08 16:09:05 -04:00
Jeremy Stretch
68515b9d46 Merge pull request #203 from dinoocch/ldap-docs
Add LDAP Authentication Documentation
2016-07-08 16:08:07 -04:00
Jeremy Stretch
c948682370 Removed TOC elements 2016-07-08 16:05:14 -04:00
Jeremy Stretch
5758ce2be4 Reorganized installation docs 2016-07-08 16:00:53 -04:00
Jeremy Stretch
27c8cb046c Structured docs layout 2016-07-08 12:55:57 -04:00
Jeremy Stretch
b6e87d1526 Merge branch 'new-docs' into develop 2016-07-08 12:08:32 -04:00
Jeremy Stretch
90dadfc5d9 Moving docs to readthedocs.org 2016-07-08 12:07:04 -04:00
Jeremy Stretch
edc3ab597f Merge pull request #232 from digitalocean/api-integration
Add initial API Integration document
2016-07-08 12:05:28 -04:00
stanley karunditu
4372043ddb Potential quick fix for Issue #215. The proper solution is to
redo the grid layout for the page so that its fully responsive.
It is only partial responsive. Did tests using Firefox developer tools.
2016-07-08 07:06:15 -07:00
Jeremy Stretch
db72a64ef7 Changed DeviceForm.device_type label 2016-07-07 22:30:51 -04:00
Matt Layher
eb9315c11c Add initial API Integration document 2016-07-07 16:18:34 -04:00
Jeremy Stretch
b9e0739f72 Fixes #228: Correct conditional inclusion of device bays 2016-07-07 13:39:54 -04:00
Matt Layher
29358a18b8 Merge remote-tracking branch 'origin/master' into develop 2016-07-07 13:01:24 -04:00
Jeremy Stretch
9c48340b9a Dev version bump 2016-07-07 12:54:25 -04:00
Jeremy Stretch
4e4996e88f Merge branch 'develop' 2016-07-07 12:49:56 -04:00
Jeremy Stretch
ece16200a3 Resolved version conflict 2016-07-07 12:49:35 -04:00
Jeremy Stretch
bfe8979523 Version bump for v1.1.0 2016-07-07 12:27:27 -04:00
Jeremy Stretch
7228801cb0 Move membership evaluation to SecretRole 2016-07-07 12:07:02 -04:00
Gelob
1338bf6012 Banner/MOTD Support 2016-07-06 23:37:04 -05:00
Jeremy Stretch
c19124fcac Clarified secret role permissions 2016-07-06 17:45:08 -04:00
Jeremy Stretch
edde021c85 Grant superusers permission to decrypt all secrets 2016-07-06 17:40:32 -04:00
Jeremy Stretch
966ea45050 #68: Improved permissions-related error handling 2016-07-06 17:22:10 -04:00
Jeremy Stretch
e7f21dea4b Optimize database query for user actions list 2016-07-06 16:36:07 -04:00
Jeremy Stretch
3276caa284 Fixes #214: Suppress status message if updated_count has not been provided 2016-07-06 16:25:15 -04:00
Jeremy Stretch
891a128736 Cleaned up 'not connected' text 2016-07-06 16:01:18 -04:00
Jeremy Stretch
a74ddd8527 Merge pull request #210 from peelman/develop
critical connections placeholder should span 5 rows now
2016-07-06 15:57:04 -04:00
Jeremy Stretch
24c48bece8 Fixes #209: Read pk list from POST instead of form 2016-07-06 15:55:42 -04:00
Nick Peelman
a069e92ce0 critical connections placeholder should span 5 rows now 2016-07-06 15:46:00 -04:00
Jeremy Stretch
c0ab9f70dc Standardized console/power/interface display 2016-07-06 14:40:40 -04:00
Jeremy Stretch
cc17604220 Force 48-bit MAC format for proper error messages during validation 2016-07-06 14:22:34 -04:00
Jeremy Stretch
9793b406e9 Merge pull request #170 from peelman/add-mac-address-to-interface
Add mac address to interface
2016-07-06 13:38:46 -04:00
Nick Peelman
7a2f6eaf34 Regenerate migration 2016-07-06 13:22:41 -04:00
Nick Peelman
dc847ce4d6 Fix connected interface template rendering... 2016-07-06 13:21:40 -04:00
Nick Peelman
578013fdd2 Fix PEP8 compliance...(again) 2016-07-06 13:21:40 -04:00
Nick Peelman
9f75d5bd23 Fix PEP8 compliance... 2016-07-06 13:21:40 -04:00
Nick Peelman
a6d41c95b8 Remove external macaddress package dependency 2016-07-06 13:21:40 -04:00
Nick Peelman
9da4c28cd5 Tests pass now 2016-07-06 13:21:40 -04:00
Nick Peelman
0ce92cb2ee Add fixtures for mac addresses. add mac addresses to api tests 2016-07-06 13:21:40 -04:00
Nick Peelman
6fb530b75d Relocate Add Interface button to match the style used in the rest of the view 2016-07-06 13:21:40 -04:00
Nick Peelman
5034b836ea Add MAC address field to interfaces 2016-07-06 13:21:40 -04:00
Jeremy Stretch
35f3355cfe Merge pull request #207 from ryanmerolle/develop
Updated Upgrade Instructions & apt-get install
2016-07-06 12:48:17 -04:00
ryanmerolle
65514102cd Updated Upgrade Instructions & apt-get install
I accidentally submitted my last 2 changes to master instead of develop.  This corrects the mistake.
2016-07-06 12:26:45 -04:00
Jeremy Stretch
343b65cb50 Merge pull request #205 from ryanmerolle/patch-2
Updated Upgrade Instructions
2016-07-06 11:50:01 -04:00
ryanmerolle
33d755d51a Updated Upgrade Instructions
Streamlined update process for newer users like myself.  Now includes exact instructions for downloading latest release or using git.
2016-07-06 10:10:57 -04:00
dinoocch
9750da4761 Add LDAP Authentication Documentation
Addresses #65

This commit adds documentation for installing and configuring ldap
authentication for netbox.

It may be beneficial to add settings to the configuration.py instead of
editing settings.py if this is an important feature.
2016-07-05 22:10:14 -05:00
Jeremy Stretch
d974cecda3 Fixes #199: Moved prefix_validator from BaseIPField to IPNetworkField 2016-07-05 18:15:24 -04:00
Jeremy Stretch
5e9090a03a Revert "v1.1.0 release"
This reverts commit e8b8b015bb.
2016-07-05 18:12:05 -04:00
Jeremy Stretch
60b48f9e4e Merge branch 'master' of github.com:digitalocean/netbox into develop 2016-07-05 18:00:35 -04:00
Jeremy Stretch
e8b8b015bb v1.1.0 release 2016-07-05 18:00:12 -04:00
Jeremy Stretch
7021ce2ecf Merge branch 'develop' of github.com:digitalocean/netbox into develop 2016-07-05 17:36:44 -04:00
Jeremy Stretch
58e23a9773 Merge branch 'devicetype-fixes' into develop 2016-07-05 17:36:27 -04:00
Jeremy Stretch
064582f6c5 Improve DeviceType display (credit to @peelman); add validation for DeviceType components 2016-07-05 17:30:16 -04:00
Jeremy Stretch
75789fc956 Merge pull request #188 from ChristianKniep/no_local_mounts
No local mounts
2016-07-05 16:49:12 -04:00
Jeremy Stretch
1c159968bf Merge pull request #184 from koep/develop
Add vi swap files to gitignore
2016-07-05 16:47:03 -04:00
Jeremy Stretch
1b5231c188 Merge pull request #197 from digitalocean/device-bays
Device bays
2016-07-05 16:44:06 -04:00
Jeremy Stretch
dc3cbfcdd4 Fixes #199: Moved prefix_validator from BaseIPField to IPNetworkField 2016-07-05 16:39:43 -04:00
Jeremy Stretch
ee65d3f406 Fix DeviceBay inclusion in admin UI 2016-07-05 16:35:44 -04:00
Jeremy Stretch
fc9aa03dc1 Improved device bay documentation 2016-07-05 16:20:33 -04:00
Jeremy Stretch
34c332d165 Updated documentation to include device bays 2016-07-05 16:02:11 -04:00
Jeremy Stretch
c57e63ff00 Fixed API tests 2016-07-05 15:50:21 -04:00
Jeremy Stretch
14502123d8 Minor version increment for new feature (device bays) 2016-07-05 15:34:12 -04:00
Jeremy Stretch
80c8d2f0c0 Added parent_device to DeviceSerializer 2016-07-05 15:32:16 -04:00
Nick Peelman
acccdc09f2 Fixing dumb indent mistake... 2016-07-05 15:14:03 -04:00
Nick Peelman
20e3ef9a04 make device type network/console/power tables show based on device type settings 2016-07-05 15:00:25 -04:00
Nick Peelman
db9b0dcaef Fixing a nesting/logic issue on device type edit/delete buttons 2016-07-05 14:59:29 -04:00
Jeremy Stretch
97fbfeecc3 Extended API to include DeviceBays 2016-07-05 13:43:19 -04:00
Jeremy Stretch
7eae636562 Added DeviceType to device bays table 2016-07-05 13:42:14 -04:00
Christian Kniep
af87345637 remove debug port exposure for netbox 2016-07-04 10:51:52 +02:00
Christian Kniep
85c55cd27f use volumes of netbox, holding default config 2016-07-04 10:42:24 +02:00
Christian Kniep
6e1f8d3503 put config into netbox image 2016-07-04 10:42:01 +02:00
Christian Koep
60cc88bcde Add vi swap files to gitignore 2016-07-02 17:30:12 +02:00
Jeremy Stretch
8b7d86df5a Merge branch 'develop' of github.com:digitalocean/netbox into develop 2016-07-01 18:20:27 -04:00
Jeremy Stretch
a5066a905e Added a note to CONTRIBUTING about opening an issue before submitting a PR 2016-07-01 18:20:06 -04:00
Jeremy Stretch
aabe641d63 Merge pull request #107 from koep/develop
Add option to dockerize netbox
2016-07-01 18:02:33 -04:00
Jeremy Stretch
06a38d836c Merge branch 'device-bays' of github.com:digitalocean/netbox into device-bays
Conflicts:
	netbox/dcim/tables.py
2016-07-01 17:38:21 -04:00
Jeremy Stretch
0123dbcf5f Initial work on #91: Support for subdevices 2016-07-01 17:34:47 -04:00
Jeremy Stretch
35c5423127 Merge pull request #177 from digitalocean/upgrade-sudo
Optionally use sudo if not running upgrade.sh as root
2016-07-01 17:25:59 -04:00
Jeremy Stretch
7682b66034 Merge pull request #179 from peelman/develop
Fix interface connections list view glitch
2016-07-01 17:23:20 -04:00
Jeremy Stretch
067f22e444 Initial work on #91: Support for subdevices 2016-07-01 17:12:43 -04:00
Nick Peelman
f96171f529 Fix interface connections list view glitch 2016-07-01 13:55:44 -04:00
Matt Layher
49f06cfeb2 Optionally use sudo if not running upgrade.sh as root 2016-07-01 13:21:51 -04:00
Jeremy Stretch
1bb2a3f152 Fixes #169: Fallback to cancel_url if object is missing get_absolute_url() 2016-07-01 10:25:13 -04:00
Jeremy Stretch
7a68e1d901 Post-release version bump 2016-06-30 18:29:37 -04:00
Jeremy Stretch
7f353e88c9 Merge pull request #153 from digitalocean/develop
Release v1.0.7
2016-06-30 18:20:32 -04:00
Jeremy Stretch
2829303c74 Issue #140: Fixed Unicode bug in message (ObjectEditView) 2016-06-30 18:04:14 -04:00
Jeremy Stretch
c9bf10421b Fixes #136: Trigger error on prefix w/host bits set instead of silently converting it 2016-06-30 17:13:55 -04:00
Jeremy Stretch
d2bcd71b32 Fixes #143: Noted that the lowest occupied U is used for mounting multi-U devices 2016-06-30 16:19:48 -04:00
Jeremy Stretch
3ea12c646a Fixes #109: Hide navbar for anonymous users when LOGIN_REQUIRED = True 2016-06-30 16:02:18 -04:00
Christian Koep
24e361dc50 Add option to dockerize netbox 2016-06-30 21:43:32 +02:00
Jeremy Stretch
381639d4a7 Wrapped pip updates inside sudo 2016-06-30 14:59:52 -04:00
Jeremy Stretch
cf17088b0a Merge pull request #145 from ryanmerolle/patch-1
standardized app installation commands
2016-06-30 14:38:31 -04:00
ryanmerolle
a165445808 Update getting-started.md 2016-06-30 13:11:43 -04:00
ryanmerolle
66d8c27b1e standardized app installation commands
* Standardized all install commands to use sudo & -y to avoid new user confusion.
* Added required missing installation (apache2 missing in Web Server and gunicorn section)
* Consolidated installs for a section to follow same format (Web Server and gunicorn section nginx)
* Added missing install argument when installing the arbitrary precision calculator language.
2016-06-30 13:02:06 -04:00
Jeremy Stretch
85f3324d97 Fixes #141: Removed invalid character 2016-06-30 11:39:25 -04:00
Jeremy Stretch
a010a6dde5 Corrected instruction for cloning the repo 2016-06-29 23:49:59 -04:00
Jeremy Stretch
1c49909e2c Fixed typo 2016-06-29 23:16:16 -04:00
Jeremy Stretch
019daf5524 Fixes #135: Add button to toggle navbar on small screens 2016-06-29 22:51:10 -04:00
Jeremy Stretch
519ab21ba0 Version bump for next release 2016-06-29 17:48:11 -04:00
Jeremy Stretch
26286b6e36 Merge pull request #125 from digitalocean/develop
Release v1.0.6
2016-06-29 17:43:40 -04:00
Jeremy Stretch
d520d78380 Merge pull request #119 from bellwood/lets-encrypt-nginx-ssl
Update getting-started.md
2016-06-29 17:32:49 -04:00
Jeremy Stretch
46ae4b307c Removed note about graphviz being optional; installing graphviz prevents confusing error messages 2016-06-29 16:44:56 -04:00
Jeremy Stretch
1728d81677 Added a note abotu upgrade.sh to the README 2016-06-29 16:41:23 -04:00
Jeremy Stretch
fc5495eb3b Introduced a script to assist with upgrading NetBox 2016-06-29 15:43:42 -04:00
Jeremy Stretch
004f5c448e Fixes #117: Improved device import validation 2016-06-29 14:53:24 -04:00
Jeremy Stretch
995447ae0b Suppressed '__all__' field name in BulkImportForm validation 2016-06-29 14:52:02 -04:00
bellwood
76baa6fd2d Update getting-started.md
Adding instructions for Let's Encrypt SSL and enabling HTTPS in nginx
2016-06-29 14:38:28 -04:00
Jeremy Stretch
2e27389cda Corrected capitalization of rack face in example 2016-06-29 14:16:07 -04:00
Jeremy Stretch
48d607fb96 Added VERSION to settings and page footer 2016-06-29 14:05:01 -04:00
Jeremy Stretch
b8b173674f Fixed PEP8 error 2016-06-29 13:38:51 -04:00
Jeremy Stretch
d6920eceb1 Merge pull request #100 from pitkley/replace-pydot
Replace pydot with graphviz
2016-06-29 12:53:30 -04:00
Jeremy Stretch
fbbdb3807c Fixes #108: Added search for Sites 2016-06-29 12:06:37 -04:00
Jeremy Stretch
a1953bab8b Added a link to the GitHub issues page to the server error page 2016-06-29 11:04:34 -04:00
Jeremy Stretch
aa000bf26d Fixes #110: Added status field to bulk editing form for Prefixes and VLANs 2016-06-29 10:52:06 -04:00
Jeremy Stretch
c93bc40479 Merge pull request #106 from digitalocean/develop
Release v1.0.5
2016-06-29 10:00:46 -04:00
Jeremy Stretch
4ed3d54566 Fixes #103: Corrected VRF filters for Prefixes and IPAddresses 2016-06-29 09:45:59 -04:00
Pit Kleyersburg
522a0c20e7 Replace pydot by graphviz
This is in an effort to support Python 3: pydot is not compatible with
Python 3, while graphviz is.
2016-06-29 11:25:36 +02:00
Jeremy Stretch
b02c54ce52 A modest attempt at improving interface ordering; see #9 2016-06-28 23:22:41 -04:00
Jeremy Stretch
43e030f1db Fixes #83: Corrected example Apache configuration 2016-06-28 20:21:49 -04:00
Jeremy Stretch
945ca31460 Fixes #92: Redirect to module creation page on 'add another' 2016-06-28 17:12:09 -04:00
Jeremy Stretch
fc3cb72ab8 Merge pull request #82 from digitalocean/contributing-checklist
Add sanity check checklist for submitting pull requests
2016-06-28 16:26:20 -04:00
Jeremy Stretch
4a04af145b Fixed VRF filter for API 2016-06-28 16:01:48 -04:00
Jeremy Stretch
e7615cf32f Added instructions for upgrading NetBox 2016-06-28 15:58:50 -04:00
Jeremy Stretch
8b357a311d Fixes #61: Added list of RackGroups to Site view 2016-06-28 14:53:33 -04:00
Jeremy Stretch
fdfc32899d Fixes #75: Ignore a Device's occupied rack units when relocating it within a rack 2016-06-28 14:10:16 -04:00
Jeremy Stretch
03fa000d8d Merge pull request #86 from digitalocean/iface_form_factors
Fixes #84: Added IFACE_FF_10GE_COPPER
2016-06-28 13:38:51 -04:00
Jeremy Stretch
ec667eeed0 Fixes #84: Added IFACE_FF_10GE_COPPER 2016-06-28 13:32:47 -04:00
Jeremy Stretch
6c415794cd Corrected description of prefix and VLAN statuses 2016-06-28 12:53:43 -04:00
Jeremy Stretch
2ddb4b90c5 Merge pull request #85 from digitalocean/develop
Release v1.0.4
2016-06-28 12:17:12 -04:00
Jeremy Stretch
cce6c89810 Corrected static path in Apache config 2016-06-28 11:57:44 -04:00
Jeremy Stretch
b37503ed8f Corrected typos in the Apache config; cleaned up grammar 2016-06-28 11:50:25 -04:00
Jeremy Stretch
374702927b Fixes #80: Correct rack face (lowercase) to be consistent with export behavior (uppercase) 2016-06-28 11:38:09 -04:00
Jeremy Stretch
0eb8227044 Merge branch 'develop' of https://github.com/digitalocean/netbox into develop 2016-06-28 11:15:56 -04:00
Jeremy Stretch
98febf3979 Fixes #72: Check for re-used interfaces when importing interface connections 2016-06-28 11:11:53 -04:00
Jeremy Stretch
6a4a636794 Merge pull request #58 from digitalocean/travis-ci-pep-8
Add CI check for PEP 8 compliance
2016-06-28 10:56:42 -04:00
Matt Layher
9acd0e99f9 Add sanity check checklist for submitting pull requests 2016-06-28 10:55:38 -04:00
Matt Layher
f1857dd189 Add CI check for PEP 8 compliance 2016-06-28 10:43:38 -04:00
Jeremy Stretch
d22e4e7698 Merge pull request #59 from digitalocean/dcim-tests-pep-8
Fix PEP 8 error in DCIM tests
2016-06-28 10:42:38 -04:00
Jeremy Stretch
6848a3dc81 Fixes #67: Improved Aggregate validation; extended aggregate documentation 2016-06-28 10:04:03 -04:00
Jeremy Stretch
4dac43c1c9 Fixes #48: Set .container to auto with a max width 2016-06-28 09:50:00 -04:00
Jeremy Stretch
b392aa4a4a Fixes #45: Strip plus signs during slugification 2016-06-28 09:39:55 -04:00
Matt Layher
5181c97281 Fix PEP 8 error in DCIM tests 2016-06-28 00:25:12 -04:00
Jeremy Stretch
66a16dd06b Merge pull request #41 from digitalocean/travis-ci-tests
Run tests in CI
2016-06-28 00:10:19 -04:00
Matt Layher
c5d498ac14 Run tests in CI 2016-06-28 00:05:18 -04:00
Jeremy Stretch
2080abc6c3 Corrected SiteTest to account for earlier Graph model change 2016-06-27 23:56:39 -04:00
Jeremy Stretch
b379918295 Merge pull request #57 from digitalocean/develop
Release 1.0.3-r1
2016-06-27 23:24:01 -04:00
Jeremy Stretch
4e5f537cc5 When editing an object, cancel_url should point to its normal view; when adding, it should point to the object list 2016-06-27 23:18:26 -04:00
Jeremy Stretch
7918f85cdd Corrected rack height validation to exclude 0U devices 2016-06-27 23:08:30 -04:00
Jeremy Stretch
df1147d941 Merge pull request #56 from digitalocean/develop
Release 1.0.3
2016-06-27 22:55:09 -04:00
Jeremy Stretch
65bc91e9de Merge branch 'develop' of https://github.com/digitalocean/netbox into develop 2016-06-27 22:48:55 -04:00
Jeremy Stretch
9aa0972a8c Corrected regex to ignore shell files in root dir 2016-06-27 22:48:24 -04:00
Jeremy Stretch
4cd6f99cbd Merge pull request #52 from alexconrey/develop
added apache config information to getting-started.md
2016-06-27 22:39:58 -04:00
Jeremy Stretch
df01947c9e Corrected claim about RIRs being pre-populated 2016-06-27 22:35:07 -04:00
Jeremy Stretch
4dd31497e5 Fixes #26: Corrected rack validation to work when there are no devices within the rack 2016-06-27 22:27:40 -04:00
Jeremy Stretch
f958bc0580 Merge pull request #47 from digitalocean/readme-travis-badges
Add Travis build badges for both master and develop against python 2.7
2016-06-27 22:16:49 -04:00
Jeremy Stretch
0a22821209 Merge pull request #46 from digitalocean/contributing-prs
Add Submitting Pull Requests section to CONTRIBUTING
2016-06-27 22:16:13 -04:00
Alex Conrey
a4cbfd7d5b added apache config information to getting-started.md 2016-06-27 19:51:46 -05:00
Matt Layher
f0fb60734a Add Travis build badges for both master and develop against python 2.7 2016-06-27 19:55:17 -04:00
Matt Layher
e334c64a7c Add Submitting Pull Requests section to CONTRIBUTING 2016-06-27 19:49:57 -04:00
Jeremy Stretch
6e068770ea Merge pull request #38 from digitalocean/travis-ci
Add Travis CI build
2016-06-27 16:54:30 -04:00
Matt Layher
d5d4eb9fd5 Add Travis CI build 2016-06-27 16:48:54 -04:00
Jeremy Stretch
ab880e1053 Fixed IPAddress 'parent prefixes' display; added warning for duplicate IPs 2016-06-27 15:51:47 -04:00
Jeremy Stretch
1ea8f04c23 Added a note about the IRC chanel to the README 2016-06-27 15:23:06 -04:00
Jeremy Stretch
0b37d4f5e6 Merge pull request #31 from digitalocean/develop
Release 1.0.2
2016-06-27 14:53:27 -04:00
Jeremy Stretch
c6e66a073d Fixes #29: Corrected typo 2016-06-27 14:39:08 -04:00
Jeremy Stretch
eade3cbd6b Fixes #25: Recurse expand_pattern only if there are more ranges to unpack 2016-06-27 14:12:30 -04:00
Jeremy Stretch
7cf437e11b Fixes #26: Added rack height validation 2016-06-27 13:53:39 -04:00
Jeremy Stretch
19a302774a Changed gunicorn path to since that appears to be standard for Ubuntu now 2016-06-27 13:21:38 -04:00
Jeremy Stretch
a35d927235 Merge pull request #23 from digitalocean/develop
1.0.1
2016-06-27 12:35:48 -04:00
Jeremy Stretch
1cd20861f2 Added RackGroup slug filter 2016-06-27 12:30:25 -04:00
Jeremy Stretch
e78263637a Fixes #22: Corrected handling of RackGroup names on import of new Racks 2016-06-27 12:23:31 -04:00
Jeremy Stretch
215c31e7a0 Fixes #21: Added screenshots to README 2016-06-27 11:43:33 -04:00
Jeremy Stretch
5935a8843e Improved maintenance mode message 2016-06-27 11:22:36 -04:00
119 changed files with 3011 additions and 5211 deletions

5
.gitignore vendored
View File

@@ -1,6 +1,7 @@
*.pyc
configuration.py
.idea
*.sh
/*.sh
!upgrade.sh
fabfile.py
*.swp

8
.travis.yml Normal file
View File

@@ -0,0 +1,8 @@
language: python
python:
- "2.7"
install:
- pip install -r requirements.txt
- pip install pep8
script:
- ./scripts/cibuild.sh

View File

@@ -27,9 +27,10 @@ IRC.
## Feature Requests
* First, check the [issues list](https://github.com/digitalocean/netbox/issues) to see if the feature you'd like to see
has already been requested (and possibly rejected). If it is, be sure to comment with a "+1" and any additional
justification you have for the feature.
* First, check the [issues list](https://github.com/digitalocean/netbox/issues) to see if the feature you're requesting
has already been requested (and possibly rejected). If it has, click "add a reaction" in the top right corner of the
issue and add a thumbs up (+1). This ensures that the issue has a better chance of making it onto the roadmap. Also feel
free to add a comment with any additional justification for the feature.
* While discussion of new features is welcome, it's important to limit the scope of NetBox's feature set to avoid
feature creep. For example, the following features would be firmly out of scope for NetBox:
@@ -48,3 +49,19 @@ Even if it's not quite right for NetBox, we may be able to point you to a tool b
* A use case for the feature; who would use it and what value it would add to NetBox
* A rough description of any changes necessary to the database schema (if applicable)
* Any third-party libraries or other resources which would be involved
## Submitting Pull Requests
* Be sure to open an issue before starting work on a pull request, and discuss your idea with the NetBox maintainers
before beginning work. This will help prevent wasting time on something that might we might not be able to implement.
When suggesting a new feature, also make sure it won't conflict with any work that's already in progress.
* When submitting a pull request, please be sure to work off of branch `develop`, rather than branch `master`.
In NetBox, the `develop` branch is used for ongoing development, while `master` is used for tagging new
stable releases.
* All code submissions should meet the following criteria (CI will enforce these checks):
* Python syntax is valid
* All tests pass when run with `./manage.py test netbox/`
* PEP 8 compliance is enforced, with the exception that lines may be greater than 80 characters in length

30
Dockerfile Normal file
View File

@@ -0,0 +1,30 @@
FROM ubuntu:14.04
RUN apt-get update && apt-get install -y \
python2.7 \
python-dev \
git \
python-pip \
libxml2-dev \
libxslt1-dev \
libffi-dev \
graphviz \
libpq-dev \
build-essential \
gunicorn \
--no-install-recommends \
&& rm -rf /var/lib/apt/lists/* \
&& mkdir -p /opt/netbox \
&& cd /opt/netbox \
&& git clone --depth 1 https://github.com/digitalocean/netbox.git -b master . \
&& pip install -r requirements.txt \
&& apt-get purge -y --auto-remove git build-essential
ADD docker/docker-entrypoint.sh /docker-entrypoint.sh
ADD netbox/netbox/configuration.docker.py /opt/netbox/netbox/netbox/configuration.py
ENTRYPOINT [ "/docker-entrypoint.sh" ]
ADD docker/gunicorn_config.py /opt/netbox/
ADD docker/nginx.conf /etc/netbox-nginx/
VOLUME ["/etc/netbox-nginx/"]

View File

@@ -1,55 +1,30 @@
# NetBox
NetBox is an IP address management (IPAM) and data center infrastructure management (DCIM) tool. Initially conceived by the network engineering team at [DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically to address the needs of network and infrastructure engineers.
NetBox runs as a web application atop the [Django](https://www.djangoproject.com/) Python framework with a [PostgreSQL](http://www.postgresql.org/) database. For a complete list of requirements, see `requirements.txt`. The code is available [on GitHub](https://github.com/digitalocean/netbox).
The complete documentation for Netbox can be found at [Read the Docs](http://netbox.readthedocs.io/en/latest/).
Questions? Comments? Please join us on IRC in **#netbox** on **irc.freenode.net**!
### Build Status
| | python 2.7 |
|-------------|------------|
| **master** | [![Build Status](https://travis-ci.org/digitalocean/netbox.svg?branch=master)](https://travis-ci.org/digitalocean/netbox) |
| **develop** | [![Build Status](https://travis-ci.org/digitalocean/netbox.svg?branch=develop)](https://travis-ci.org/digitalocean/netbox) |
## Screenshots
![Screenshot of main page](docs/media/screenshot1.png "Main page")
![Screenshot of rack elevation](docs/media/screenshot2.png "Rack elevation")
![Screenshot of prefix hierarchy](docs/media/screenshot3.png "Prefix hierarchy")
# Installation
Please see docs/getting-started.md for instructions on installing NetBox.
# Components
NetBox understands all of the physical and logical building blocks that comprise network infrastructure, and the manners in which they are all related.
## DCIM
DCIM comprises all the physical installations and connections which comprise a network. NetBox tracks where devices are installed, as well as their individual power, console, and network connections.
**Site:** A physical location (typically a building) where network devices are installed. Devices in different sites cannot be directly connected to one another.
**Rack:** An equipment rack into which devices are installed. Each rack belongs to a site.
**Device:** Any type of rack-mounted device. For example, routers, switches, servers, console servers, PDUs, etc. 0U (non-rack-mounted) devices are supported.
## IPAM
IPAM deals with the IP addressing and VLANs in use on a network. NetBox makes a distinction between IP prefixes (networks) and individual IP addresses.
Because NetBox is a combined DCIM/IPAM system, IP addresses can be assigned to device interfaces in the application just as they are in the real world.
**Aggregate:** A top-level aggregate of IP address space; for example, 10.0.0.0/8 or 2001:db8::/32. Each aggregate belongs to a regional Internet registry (RIR) like ARIN or RIPE, or to an authoritative standard such as RFC 1918.
**VRF:** A virtual routing table. VRF support is currently still under development.
**Prefix:** An IPv4 or IPv6 network. A prefix can be assigned to a VRF; if not, it is considered to belong to the global table. Prefixes are grouped by aggregates automatically and can optionally be assigned to sites.
**IP Address:** An individual IPv4 or IPv6 address (with CIDR mask). IP address can be assigned to device interfaces.
**VLAN:** VLANs are assigned to sites, and can optionally have one or more IP prefixes assigned to them. VLAN IDs are unique only within the scope of a site.
## Circuits
Long-distance data connections are typically referred to as _circuits_. NetBox provides a method for managing circuits and their providers. Individual circuits can be terminated to device interfaces.
**Provider:** An entity to which a network connects to. This can be a transit provider, peer, or some other organization.
**Circuit:** A data circuit which connects to a provider. The local end of a circuit can be assigned to a device interface.
## Secrets
NetBox provides encrypted storage of sensitive data it calls _secrets_. Each user may be issued an encryption key with which stored secrets can be retrieved.
Note that NetBox does not merely hash secrets, a function which is only useful for validation. It employs fully reversible AES-256 encryption so that secret data can be retrieved and consumed by other services.
**Secrets** Any piece of confidential data which must be retrievable. For example: passwords, SNMP communities, RADIUS shared secrets, etc.
**User Key:** An individual user's encrypted copy of the master key, which can be used to retrieve secret data.
To upgrade NetBox, please download the [latest release](https://github.com/digitalocean/netbox/releases) and run `upgrade.sh`.

53
docker-compose.yml Normal file
View File

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

22
docker/docker-entrypoint.sh Executable file
View File

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

View File

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

35
docker/nginx.conf Normal file
View File

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

19
docs/api-integration.md Normal file
View File

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

View File

@@ -0,0 +1,45 @@
NetBox's local configuration is held in `netbox/netbox/configuration.py`. An example configuration is provided at `netbox/netbox/configuration.example.py`. You may copy or rename the example configuration and make changes as appropriate. NetBox will not run without a configuration file.
## ALLOWED_HOSTS
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', '192.0.2.123']
```
---
## DATABASE
NetBox requires access to a PostgreSQL database service to store data. This service can run locally or on a remote system. The following parameters must be defined within the `DATABASE` dictionary:
* NAME - Database name
* USER - PostgreSQL username
* PASSWORD - PostgreSQL password
* HOST - Name or IP address of the database server (use `localhost` if running locally)
* PORT - TCP port of the PostgreSQL service; leave blank for default port (5432)
Example:
```
DATABASE = {
'NAME': 'netbox', # Database name
'USER': 'netbox', # PostgreSQL username
'PASSWORD': 'J5brHrAXFLQSif0K', # PostgreSQL password
'HOST': 'localhost', # Database server
'PORT': '', # Database port (leave blank for default)
}
```
---
## SECRET_KEY
This is a secret cryptographic key is used to improve the security of cookies and password resets. The key defined here should not be shared outside of the configuration file. `SECRET_KEY` can be changed at any time, however be aware that doing so will invalidate all existing sessions.
Please note that this key is **not** used for hashing user passwords or for the encrypted storage of secret data in NetBox.
`SECRET_KEY` should be at least 50 characters in length and contain a random mix of letters, digits, and symbols. The script located at `netbox/generate_secret_key.py` may be used to generate a suitable key.

View File

@@ -1,62 +1,6 @@
<h1>Configuration</h1>
The following are optional settings which may be declared in `netbox/netbox/configuration.py`.
NetBox's local configuration is held in `netbox/netbox/configuration.py`. An example configuration is provided at `netbox/netbox/configuration.example.py`. You may copy or rename the example configuration and make changes as appropriate. NetBox will not run without a configuration file.
[TOC]
# Mandatory Settings
---
#### ALLOWED_HOSTS
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', '192.0.2.123']
```
---
#### DATABASE
NetBox requires access to a PostgreSQL database service to store data. This service can run locally or on a remote system. The following parameters must be defined within the `DATABASE` dictionary:
* NAME - Database name
* USER - PostgreSQL username
* PASSWORD - PostgreSQL password
* HOST - Name or IP address of the database server (use `localhost` if running locally)
* PORT - TCP port of the PostgreSQL service; leave blank for default port (5432)
Example:
```
DATABASE = {
'NAME': 'netbox', # Database name
'USER': 'netbox', # PostgreSQL username
'PASSWORD': 'J5brHrAXFLQSif0K', # PostgreSQL password
'HOST': 'localhost', # Database server
'PORT': '', # Database port (leave blank for default)
}
```
---
#### SECRET_KEY
This is a secret cryptographic key is used to improve the security of cookies and password resets. The key defined here should not be shared outside of the configuration file. `SECRET_KEY` can be changed at any time, however be aware that doing so will invalidate all existing sessions.
Please note that this key is **not** used for hashing user passwords or for the encrypted storage of secret data in NetBox.
`SECRET_KEY` should be at least 50 characters in length and contain a random mix of letters, digits, and symbols. The script located at `netbox/generate_secret_key.py` may be used to generate a suitable key.
# Optional Settings
---
#### ADMINS
## ADMINS
NetBox will email details about critical errors to the administrators listed here. This should be a list of (name, email) tuples. For example:
@@ -69,7 +13,20 @@ ADMINS = [
---
#### DEBUG
## BANNER_TOP
## 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:
```
BANNER_TOP = 'Your banner text'
BANNER_BOTTOM = BANNER_TOP
```
---
## DEBUG
Default: False
@@ -77,7 +34,7 @@ This setting enables debugging. This should be done only during development or t
---
#### EMAIL
## EMAIL
In order to send email, NetBox needs an email server configured. The following items can be defined within the `EMAIL` setting:
@@ -90,7 +47,7 @@ In order to send email, NetBox needs an email server configured. The following i
---
#### LOGIN_REQUIRED
## LOGIN_REQUIRED
Default: False,
@@ -98,7 +55,7 @@ Setting this to True will permit only authenticated users to access any part of
---
#### MAINTENANCE_MODE
## MAINTENANCE_MODE
Default: False
@@ -106,15 +63,15 @@ Setting this to True will display a "maintenance mode" banner at the top of ever
---
#### NETBOX_USERNAME
## NETBOX_USERNAME
#### NETBOX_PASSWORD
## NETBOX_PASSWORD
If provided, NetBox will use these credentials to authenticate against devices when collecting data.
---
#### PAGINATE_COUNT
## PAGINATE_COUNT
Default: 50
@@ -122,7 +79,15 @@ Determine how many objects to display per page within each list of objects.
---
#### TIME_ZONE
## PREFER_IPV4
Default: False
When determining the primary IP address for a device, IPv6 is preferred over IPv4 by default. Set this to True to prefer IPv4 instead.
---
## TIME_ZONE
Default: UTC
@@ -130,7 +95,7 @@ The time zone NetBox will use when dealing with dates and times. It is recommend
---
#### Date and Time Formatting
## Date and Time Formatting
You may define custom formatting for date and times. For detailed instructions on writing format strings, please see [the Django documentation](https://docs.djangoproject.com/en/dev/ref/templates/builtins/#date).

View File

@@ -1,9 +1,5 @@
<h1>Circuits</h1>
The circuits component of NetBox deals with the management of long-haul Internet and private transit links and providers.
[TOC]
# Providers
A provider is any entity which provides some form of connectivity. This obviously includes carriers which offer Internet and private transit service. However, it might also include Internet exchange (IX) points and even organizations with whom you peer directly.

View File

@@ -1,9 +1,5 @@
<h1>DCIM</h1>
Data center infrastructure management (DCIM) entails all physical assets: sites, racks, devices, cabling, etc.
[TOC]
# Sites
How you define sites will depend on the nature of your organization, but typically a site will equate a building or campus. For example, a chain of banks might create a site to represent each of its branches, a site for its corporate headquarters, and two additional sites for its presence in two colocation facilities.
@@ -43,6 +39,7 @@ Each device type is assigned a number of component templates which describe the
* Power port templates
* Power outlet templates
* Interface templates
* Device bay templates
Whenever a new device is created, it is automatically assigned console, power, and interface components per the templates assigned to its device type. For example, suppose your network employs Juniper EX4300-48T switches. You would create a device type with a model name "EX4300-48T" and assign it to the manufacturer "Juniper." You might then also create the following templates for it:
@@ -59,12 +56,12 @@ Note that assignment of components from templates occurs only at the time of dev
# Devices
Every piece of hardware which is installed within a rack exists in NetBox as a device. Devices are measured in rack units (U) and whether they are full depth. 0U devices which can be installed in a rack but don't consume vertical rack space (such as a vertically-mounted power distribution unit) can also be defined.
Every piece of hardware which is installed within a rack exists in NetBox as a device. Devices are measured in rack units (U) and depth. 0U devices which can be installed in a rack but don't consume vertical rack space (such as a vertically-mounted power distribution unit) can also be defined.
When assigning a multi-U device to a rack, it is considered to be mounted in the lowest-numbered rack unit which it occupies. For example, a 3U device which occupies U8 through U10 shows as being mounted in U8.
A device is said to be "full depth" if its installation on one rack face prevents the installation of any other device on the opposite face within the same rack unit(s). This could be either because the device is physically too deep to allow a device behind it, or because the installation of an opposing device would impede air flow.
Each device has a physical device type (make and model), which is discussed below.
### Roles
NetBox allows for the definition of arbitrary device roles by which devices can be organized. For example, you might create roles for core switches, distribution switches, and access switches. In the interest of simplicity, device can only belong to one device role.
@@ -81,16 +78,19 @@ A device can be assigned modules which represent internal components. Currently,
### Components
There are five types of device components which comprise all of the interconnection logic with NetBox:
There are six types of device components which comprise all of the interconnection logic with NetBox:
* Console ports
* Console server ports
* Power ports
* Power outlets
* Interfaces
* Device bays
Console ports connect only to console server ports, and power ports connect only to power outlets. Interfaces connect to one another in a symmetric manner: If interface A connects to interface B, interface B therefore connects to interface A. (The relationship between two interfaces is actually represented in the database by an InterfaceConnection object, but this is transparent to the user.)
Each type of connection can be defined as either *planned* or *connected*. This allows for easily denoting connections which have not yet been installed.
Each type of connection can be classified as either *planned* or *connected*. This allows for easily denoting connections which have not yet been installed. In addition to a connecting peer, interfaces are also assigned a form factor and may be designated as management-only (for out-of-band management). Interfaces may also be assigned a short description.
In addition to a connecting peer, interfaces are also assigned a form factor and may be designated as management-only (for out-of-band management). Interfaces may also be assigned a short description.
Device bays represent the ability of a device to house child devices. For example, you might install four blade servers into a 2U chassis. The chassis would appear in the rack elevation as a 2U device with four device bays. Each server within it would be defined as a 0U device installed in one of the device bays. Child devices do not appear on rack elevations, but they are included in the "Non-Racked Devices" list within the rack view.
Note that child devices differ from modules in that they are still treated as independent devices, with their own console/power/data components, modules, and IP addresses. Modules, on the other hand, are parts within a device, such as a hard disk or power supply.

View File

@@ -1,9 +1,5 @@
<h1>Extras</h1>
This section entails features of NetBox which are not crucial to its primary functions, but that provide additional value.
[TOC]
# Export Templates
NetBox allows users to define custom templates that can be used when exporting objects. To create an export template, navigate to Extras > Export Templates under the admin interface.

View File

@@ -1,9 +1,5 @@
<h1>IPAM</h1>
IP address management (IPAM) entails the allocation of IP networks, addresses, and related numeric resources.
[TOC]
# VRFs
A VRF object in NetBox represents a virtual routing and forwarding (VRF) domain within a network. Each VRF is essentially a separate routing table: the same IP prefix or address can exist in multiple VRFs. VRFs are commonly used to isolate customers or organizations from one another within a network.
@@ -32,11 +28,13 @@ Additionally, you might define an aggregate for each large swath of public IPv4
Any prefixes you create in NetBox (discussed below) will be automatically organized under their respective aggregates. Any space within an aggregate which is not covered by an existing prefix will be annotated as available for allocation.
Aggregates cannot overlap with one another; they can only exist in parallel. For instance, you cannot define both 10.0.0.0/8 and 10.16.0.0/16 as aggregates, because they overlap. 10.16.0.0/16 in this example would be created as a prefix.
### RIRs
Regional Internet Registries (RIRs) are responsible for the allocation of global address space. The five RIRs are ARIN, RIPE, APNIC, LACNIC, and AFRINIC. However, some address space has been set aside for private or internal use only, such as defined in RFCs 1918 and 6598. NetBox considers these RFCs as a sort of RIR as well; that is, an authority which "owns" certain address space.
Each aggregate must be assigned to one RIR. NetBox by default will be populated with the RIRs listed above, however you are free to remove these and/or create your own if you choose.
Each aggregate must be assigned to one RIR. You are free to define whichever RIRs you choose (or create your own).
---
@@ -50,15 +48,13 @@ A prefix may optionally be assigned to one VLAN; a VLAN may have multiple prefix
### Statuses
Each prefix is assigned an operational status. This may be one of the following:
Each prefix is assigned an operational status. This is one of the following:
* Container - A summary of child prefixes
* Active - Provisioned and in use
* Reserved - Earmarked for future use
* Deprecated - No longer in use
NetBox provides several statuses by default, but you are free to change them to suit the needs of your organization.
### Roles
Whereas a status describes a prefix's operational state, a role describes its function. For example, roles might include:
@@ -69,7 +65,7 @@ Whereas a status describes a prefix's operational state, a role describes its fu
* Lab
* Out-of-band
Role assignment is optional. And like statuses, you are free to create your own.
Role assignment is optional and you are free to create as many as you'd like.
---

View File

@@ -1,9 +1,5 @@
<h1>Secrets</h1>
"Secrets" are small amounts of data that must be kept confidential; for example, passwords and SNMP community strings. NetBox provides encrypted storage of secret data.
[TOC]
# Secrets
A secret represents a single credential or other string which must be stored securely. Each secret is assigned to a device within NetBox. The plaintext value of a secret is encrypted to a ciphertext immediately prior to storage within the database using a 256-bit AES master key. A SHA256 hash of the plaintext is also stored along with each ciphertext to validate the decrypted plaintext.
@@ -20,6 +16,8 @@ Each secret is assigned a functional role which indicates what it is used for. T
* IKE key strings
* Routing protocol shared secrets
Roles are also used to control access to secrets. Each role is assigned an arbitrary number of groups and/or users. Only the users associated with a role have permission to decrypt the secrets assigned to that role. (A superuser has permission to decrypt all secrets, provided they have an active user key.)
---
# User Keys

View File

@@ -1,291 +0,0 @@
<h1>Getting Started</h1>
This guide documents the process of installing NetBox on an Ubuntu 14.04 server with [nginx](https://www.nginx.com/) and [gunicorn](http://gunicorn.org/).
[TOC]
# PostgreSQL
## Installation
The following packages are needed to install PostgreSQL:
* postgresql
* libpq-dev
* python-psycopg2
```
# apt-get install postgresql libpq-dev python-psycopg2
```
## Configuration
At a minimum, we need to create a database for NetBox and assign it a username and password for authentication. This is done with the following commands.
DO NOT USE THE PASSWORD FROM THE EXAMPLE.
```
# sudo -u postgres psql
psql (9.3.13)
Type "help" for help.
postgres=# CREATE DATABASE netbox;
CREATE DATABASE
postgres=# CREATE USER netbox WITH PASSWORD 'J5brHrAXFLQSif0K';
CREATE ROLE
postgres=# GRANT ALL PRIVILEGES ON DATABASE netbox TO netbox;
GRANT
postgres=# \q
```
You can verify that authentication works using the following command:
```
# psql -U netbox -h localhost -W
```
---
# NetBox
## Dependencies
* python2.7
* python-dev
* git
* python-pip
* libxml2-dev
* libxslt1-dev
* libffi-dev
* graphviz*
```
# apt-get install python2.7 python-dev git python-pip libxml2-dev libxslt1-dev libffi-dev graphviz
```
*graphviz is needed to render topology maps. If you have no need for this feature, graphviz is not required.
## Clone the Git Repository
Create the base directory for the NetBox installation. For this guide, we'll use `/opt/netbox`.
```
# mkdir -p /opt/netbox/
# cd /opt/netbox/
```
Next, clone the NetBox git repository into the current directory:
```
# git clone https://github.com/digitalocean/netbox.git .
Cloning into '.'...
remote: Counting objects: 1994, done.
remote: Compressing objects: 100% (150/150), done.
remote: Total 1994 (delta 80), reused 0 (delta 0), pack-reused 1842
Receiving objects: 100% (1994/1994), 472.36 KiB | 0 bytes/s, done.
Resolving deltas: 100% (1495/1495), done.
Checking connectivity... done.
```
Install the necessary Python packages using pip. (If you encounter any compilation errors during this step, ensure that you've installed all of the required dependencies.)
```
# pip install -r requirements.txt
```
## Configuration
Move into the NetBox configuration directory and make a copy of `configuration.example.py` named `configuration.py`.
```
# cd netbox/netbox/
# cp configuration.example.py configuration.py
```
Open `configuration.py` with your preferred editor and set the following variables:
* ALLOWED_HOSTS
* DATABASE
* SECRET_KEY
### ALLOWED_HOSTS
This is a list of the valid hostnames by which this server can be reached. You must specify at least one name or IP address.
Example:
```
ALLOWED_HOSTS = ['netbox.example.com', '192.0.2.123']
```
### DATABASE
This parameter holds the database configuration details. You must define the username and password used when you configured PostgreSQL. If the service is running on a remote host, replace `localhost` with its address.
Example:
```
DATABASE = {
'NAME': 'netbox', # Database name
'USER': 'netbox', # PostgreSQL username
'PASSWORD': 'J5brHrAXFLQSif0K', # PostgreSQL password
'HOST': 'localhost', # Database server
'PORT': '', # Database port (leave blank for default)
}
```
### SECRET_KEY
Generate a random secret key of at least 50 alphanumeric characters. This key must be unique to this installation and must not be shared outside the local system.
You may use the script located at `netbox/generate_secret_key.py` to generate a suitable key.
## Run Migrations
Before NetBox can run, we need to install the database schema. This is done by running `./manage.py migrate` from the `netbox` directory (`/opt/netbox/netbox/` in our example):
```
# ./manage.py migrate
Operations to perform:
Apply all migrations: dcim, sessions, admin, ipam, utilities, auth, circuits, contenttypes, extras, secrets, users
Running migrations:
Rendering model states... DONE
Applying contenttypes.0001_initial... OK
Applying auth.0001_initial... OK
Applying admin.0001_initial... OK
...
```
If this step results in a PostgreSQL authentication error, ensure that the username and password created in the database match what has been specified in `configuration.py`
## Create a Super User
NetBox does not come with any predefined user accounts. You'll need to create a super user to be able to log into NetBox:
```
# ./manage.py createsuperuser
Username: admin
Email address: admin@example.com
Password:
Password (again):
Superuser created successfully.
```
## Collect Static Files
```
# ./manage.py collectstatic
You have requested to collect static files at the destination
location as specified in your settings:
/opt/netbox/netbox/static
This will overwrite existing files!
Are you sure you want to do this?
Type 'yes' to continue, or 'no' to cancel: yes
```
## Test the Application
At this point, NetBox should be able to run. We can verify this by starting a development instance:
```
# ./manage.py runserver 0.0.0.0:8000 --insecure
Performing system checks...
System check identified no issues (0 silenced).
June 17, 2016 - 16:17:36
Django version 1.9.7, using settings 'netbox.settings'
Starting development server at http://0.0.0.0:8000/
Quit the server with CONTROL-C.
```
Now if we navigate to the name or IP of the server (as defined in `ALLOWED_HOSTS`) we should be greeted with the NetBox home page. Note that this built-in web service is for development and testing purposes only. It is not suited for production use.
If the test service does not run, or you cannot reach the NetBox home page, something has gone wrong. Do not proceed with the rest of this guide until the installation has been corrected.
# nginx and gunicorn
## Installation
We'll set up a simple HTTP front end using [nginx](https://www.nginx.com/resources/wiki/) and [gunicorn](http://gunicorn.org/) for the purposes of this guide. (You are of course free to use whichever combination of HTTP and WSGI services you'd like.) We'll also use [supervisord](http://supervisord.org/) for service persistence.
```
# apt-get install nginx gunicorn supervisor
```
## nginx Configuration
The following will serve as a minimal nginx configuration. Be sure to modify your server name and installation path appropriately.
```
server {
listen 80;
server_name netbox.example.com;
access_log off;
location /static/ {
alias /opt/netbox/netbox/static/;
}
location / {
proxy_pass http://127.0.0.1:8001;
proxy_set_header X-Forwarded-Host $server_name;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
add_header P3P 'CP="ALL DSP COR PSAa PSDa OUR NOR ONL UNI COM NAV"';
}
}
```
Save this configuration to `/etc/nginx/sites-available/netbox`. Then, delete `/etc/nginx/sites-enabled/default` and create a symlink in the `sites-enabled` directory to the configuration file you just created.
```
# cd /etc/nginx/sites-enabled/
# rm default
# ln -s /etc/nginx/sites-available/netbox
```
Restart the nginx service to use the new configuration.
```
# service nginx restart
* Restarting nginx nginx
```
## gunicorn Configuration
Save the following configuration file in the root netbox installation path (in this example, `/opt/netbox/`.) as `gunicorn_config.py`. Be sure to update the `pythonpath` variable if needed.
```
command = '/usr/local/bin/gunicorn'
pythonpath = '/opt/netbox/netbox'
bind = '127.0.0.1:8001'
workers = 3
user = 'www-data'
```
## supervisord Configuration
Save the following as `/etc/supervisor/conf.d/netbox.conf`. Update the `command` and `directory` paths as needed.
```
[program:netbox]
command = gunicorn -c /opt/netbox/gunicorn_config.py netbox.wsgi
directory = /opt/netbox/netbox/
user = www-data
```
Finally, restart the supervisor service to detect and run the gunicorn service:
```
# service supervisor restart
```
At this point, you should be able to connect to the nginx HTTP service at the server name or IP address you provided. If you are unable to connect, check that the nginx service is running and properly configured. If you receive a 502 (bad gateway) error, this indicates that gunicorn is misconfigured or not running.
Please keep in mind that the configurations provided here are a bare minimum to get NetBox up and running. You will almost certainly want to make some changes to better suit your production environment.

View File

@@ -1,3 +1,53 @@
# NetBox Documentation
# What is NetBox?
NetBox is an IP address management (IPAM) and data center infrastructure management (DCIM) application.
NetBox is an open source web application designed to help manage and document computer networks. Initially conceived by the network engineering team at [DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically to address the needs of network and infrastructure engineers. It encompasses the following aspects of network management:
* **IP address management (IPAM)** - IP networks and addresses, VRFs, and VLANs
* **Equipment racks** - Organized by group and site
* **Devices** - Types of devices and where they are installed
* **Connections** - Network, console, and power connections among devices
* **Data circuits** - Long-haul communications circuits and providers
* **Secrets** - Encrypted storage of sensitive credentials
# What NetBox Isn't
While NetBox strives to cover many areas of network management, the scope of its feature set is necessarily limited. This ensures that development focuses on core functionality and that scope creep is reasonably contained. To that end, it might help to provide some examples of functionality that NetBox **does not** provide:
* Network monitoring
* DNS server
* RADIUS server
* Configuration management
* Facilities management
That said, NetBox _can_ be used to great effect in populating external tools with the data they need to perform these functions.
# Design Philosophy
NetBox was designed with the following tenets foremost in mind.
## Replicate the Real World
Careful consideration has been given to the data model to ensure that it can accurately reflect a real-world network. For instance, IP addresses are assigned not to devices, but to specific interfaces attached to a device, and an interface may have multiple IP addresses assigned to it.
## Serve as a "Source of Truth"
NetBox intends to represent the _desired_ state of a network versus its _operational_ state. As such, automated import of live network state is strongly discouraged. All data created in NetBox should first be vetted by a human to ensure its integrity. NetBox can then be used to populate monitoring and provisioning systems with a high degree of confidence.
## Keep it Simple
When given a choice between a relatively simple [80% solution](https://en.wikipedia.org/wiki/Pareto_principle) and a much more complex complete solution, the former will typically be favored. This ensures a lean codebase with a low learning curve.
# Application Stack
NetBox is built on the [Django](https://djangoproject.com/) Python framework and utilizes a [PostgreSQL](https://www.postgresql.org/) database. It runs as a WSGI service behind your choice of HTTP server.
| Function | Component |
|--------------|-------------------|
| HTTP Service | nginx or Apache |
| WSGI Service | gunicorn or uWSGI |
| Application | Django/Python |
| Database | PostgreSQL |
# Getting Started
See the [getting started](getting-started.md) guide for help with getting NetBox up and running quickly.

View File

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

101
docs/installation/ldap.md Normal file
View File

@@ -0,0 +1,101 @@
This guide explains how to implement LDAP authentication using an external server. User authentication will fall back to
built-in Django users in the event of a failure.
# Requirements
## Install openldap-devel
On Ubuntu:
```
sudo apt-get install -y python-dev libldap2-dev libsasl2-dev libssl-dev
```
On CentOS:
```
sudo yum install -y python-devel openldap-devel
```
## Install django-auth-ldap
```
sudo pip install django-auth-ldap
```
# Configuration
Create a file in the same directory as `configuration.py` (typically `netbox/netbox/`) named `ldap_config.py`. Define all of the parameters required below in `ldap_config.py`.
## General Server Configuration
```python
import ldap
# Server URI
AUTH_LDAP_SERVER_URI = "ldaps://ad.example.com"
# The following may be needed if you are binding to Active Directory.
AUTH_LDAP_CONNECTION_OPTIONS = {
ldap.OPT_REFERRALS: 0
}
# Set the DN and password for the NetBox service account.
AUTH_LDAP_BIND_DN = "CN=NETBOXSA, OU=Service Accounts,DC=example,DC=com"
AUTH_LDAP_BIND_PASSWORD = "demo"
# Include this setting if you want to ignore certificate errors. This might be needed to accept a self-signed cert.
# Note that this is a NetBox-specific setting which sets:
# ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
LDAP_IGNORE_CERT_ERRORS = True
```
## User Authentication
```python
from django_auth_ldap.config import LDAPSearch
# This search matches users with the sAMAccountName equal to the provided username. This is required if the user's
# username is not in their DN (Active Directory).
AUTH_LDAP_USER_SEARCH = LDAPSearch("ou=Users,dc=example,dc=com",
ldap.SCOPE_SUBTREE,
"(sAMAccountName=%(user)s)")
# If a user's DN is producible from their username, we don't need to search.
AUTH_LDAP_USER_DN_TEMPLATE = "uid=%(user)s,ou=users,dc=example,dc=com"
# You can map user attributes to Django attributes as so.
AUTH_LDAP_USER_ATTR_MAP = {
"first_name": "givenName",
"last_name": "sn"
}
```
# User Groups for Permissions
```python
from django_auth_ldap.config import LDAPSearch, GroupOfNamesType
# This search ought to return all groups to which the user belongs. django_auth_ldap uses this to determine group
# heirarchy.
AUTH_LDAP_GROUP_SEARCH = LDAPSearch("dc=example,dc=com", ldap.SCOPE_SUBTREE,
"(objectClass=group)")
AUTH_LDAP_GROUP_TYPE = GroupOfNamesType()
# Define a group required to login.
AUTH_LDAP_REQUIRE_GROUP = "CN=NETBOX_USERS,DC=example,DC=com"
# Define special user types using groups. Exercise great caution when assigning superuser status.
AUTH_LDAP_USER_FLAGS_BY_GROUP = {
"is_active": "cn=active,ou=groups,dc=example,dc=com",
"is_staff": "cn=staff,ou=groups,dc=example,dc=com",
"is_superuser": "cn=superuser,ou=groups,dc=example,dc=com"
}
# For more granular permissions, we can map LDAP groups to Django groups.
AUTH_LDAP_FIND_GROUP_PERMS = True
# Cache groups for one hour to reduce LDAP traffic
AUTH_LDAP_CACHE_GROUPS = True
AUTH_LDAP_GROUP_CACHE_TIMEOUT = 3600
```

184
docs/installation/netbox.md Normal file
View File

@@ -0,0 +1,184 @@
# Installation
NetBox requires following system dependencies:
* python2.7
* python-dev
* python-pip
* libxml2-dev
* libxslt1-dev
* libffi-dev
* graphviz
* libpq-dev
```
# sudo apt-get install -y python2.7 python-dev git python-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev
```
You may opt to install NetBox either from a numbered release or by cloning the master branch of its repository on GitHub.
## Option A: Download a Release
Download the [latest stable release](https://github.com/digitalocean/netbox/releases) from GitHub as a tarball or ZIP archive and extract it to your desired path. In this example, we'll use `/opt/netbox`.
```
# wget https://github.com/digitalocean/netbox/archive/vX.Y.Z.tar.gz
# tar -xzf vX.Y.Z.tar.gz -C /opt
# cd /opt/
# ln -s netbox-X.Y.Z/ netbox
# cd /opt/netbox/
```
## Option B: Clone the Git Repository
Create the base directory for the NetBox installation. For this guide, we'll use `/opt/netbox`.
```
# mkdir -p /opt/netbox/
# cd /opt/netbox/
```
If `git` is not already installed, install it:
```
# sudo apt-get install -y git
```
Next, clone the **master** branch of the NetBox GitHub repository into the current directory:
```
# git clone -b master https://github.com/digitalocean/netbox.git .
Cloning into '.'...
remote: Counting objects: 1994, done.
remote: Compressing objects: 100% (150/150), done.
remote: Total 1994 (delta 80), reused 0 (delta 0), pack-reused 1842
Receiving objects: 100% (1994/1994), 472.36 KiB | 0 bytes/s, done.
Resolving deltas: 100% (1495/1495), done.
Checking connectivity... done.
```
## Install Python Packages
Install the required Python packages using pip. (If you encounter any compilation errors during this step, ensure that you've installed all of the system dependencies listed above.)
```
# sudo pip install -r requirements.txt
```
# Configuration
Move into the NetBox configuration directory and make a copy of `configuration.example.py` named `configuration.py`.
```
# cd netbox/netbox/
# cp configuration.example.py configuration.py
```
Open `configuration.py` with your preferred editor and set the following variables:
* ALLOWED_HOSTS
* DATABASE
* SECRET_KEY
## ALLOWED_HOSTS
This is a list of the valid hostnames by which this server can be reached. You must specify at least one name or IP address.
Example:
```
ALLOWED_HOSTS = ['netbox.example.com', '192.0.2.123']
```
## DATABASE
This parameter holds the database configuration details. You must define the username and password used when you configured PostgreSQL. If the service is running on a remote host, replace `localhost` with its address.
Example:
```
DATABASE = {
'NAME': 'netbox', # Database name
'USER': 'netbox', # PostgreSQL username
'PASSWORD': 'J5brHrAXFLQSif0K', # PostgreSQL password
'HOST': 'localhost', # Database server
'PORT': '', # Database port (leave blank for default)
}
```
## SECRET_KEY
Generate a random secret key of at least 50 alphanumeric characters. This key must be unique to this installation and must not be shared outside the local system.
You may use the script located at `netbox/generate_secret_key.py` to generate a suitable key.
!!! note
In the case of a highly available installation with multiple web servers, `SECRET_KEY` must be identical among all servers in order to maintain a persistent user session state.
# Run Database Migrations
Before NetBox can run, we need to install the database schema. This is done by running `./manage.py migrate` from the `netbox` directory (`/opt/netbox/netbox/` in our example):
```
# cd /opt/netbox/netbox/
# ./manage.py migrate
Operations to perform:
Apply all migrations: dcim, sessions, admin, ipam, utilities, auth, circuits, contenttypes, extras, secrets, users
Running migrations:
Rendering model states... DONE
Applying contenttypes.0001_initial... OK
Applying auth.0001_initial... OK
Applying admin.0001_initial... OK
...
```
If this step results in a PostgreSQL authentication error, ensure that the username and password created in the database match what has been specified in `configuration.py`
# Create a Super User
NetBox does not come with any predefined user accounts. You'll need to create a super user to be able to log into NetBox:
```
# ./manage.py createsuperuser
Username: admin
Email address: admin@example.com
Password:
Password (again):
Superuser created successfully.
```
# Collect Static Files
```
# ./manage.py collectstatic
You have requested to collect static files at the destination
location as specified in your settings:
/opt/netbox/netbox/static
This will overwrite existing files!
Are you sure you want to do this?
Type 'yes' to continue, or 'no' to cancel: yes
```
# Test the Application
At this point, NetBox should be able to run. We can verify this by starting a development instance:
```
# ./manage.py runserver 0.0.0.0:8000 --insecure
Performing system checks...
System check identified no issues (0 silenced).
June 17, 2016 - 16:17:36
Django version 1.9.7, using settings 'netbox.settings'
Starting development server at http://0.0.0.0:8000/
Quit the server with CONTROL-C.
```
Now if we navigate to the name or IP of the server (as defined in `ALLOWED_HOSTS`) we should be greeted with the NetBox home page. Note that this built-in web service is for development and testing purposes only. It is not suited for production use.
!!! warning
If the test service does not run, or you cannot reach the NetBox home page, something has gone wrong. Do not proceed with the rest of this guide until the installation has been corrected.

View File

@@ -0,0 +1,42 @@
NetBox requires a PostgreSQL database to store data. MySQL is not supported, as NetBox leverage's PostgreSQL's built-in [network address types](https://www.postgresql.org/docs/9.1/static/datatype-net-types.html).
# Installation
The following packages are needed to install PostgreSQL with Python support:
* postgresql
* libpq-dev
* python-psycopg2
```
# sudo apt-get install -y postgresql libpq-dev python-psycopg2
```
# Configuration
At a minimum, we need to create a database for NetBox and assign it a username and password for authentication. This is done with the following commands.
!!! danger
DO NOT USE THE PASSWORD FROM THE EXAMPLE.
```
# sudo -u postgres psql
psql (9.3.13)
Type "help" for help.
postgres=# CREATE DATABASE netbox;
CREATE DATABASE
postgres=# CREATE USER netbox WITH PASSWORD 'J5brHrAXFLQSif0K';
CREATE ROLE
postgres=# GRANT ALL PRIVILEGES ON DATABASE netbox TO netbox;
GRANT
postgres=# \q
```
You can verify that authentication works issuing the following command and providing the configured password:
```
# psql -U netbox -h localhost -W
```
If successful, you will enter a `postgres` prompt. Type `\q` to exit.

View File

@@ -0,0 +1,61 @@
# Install the Latest Code
As with the initial installation, you can upgrade NetBox by either downloading the latest release package or by cloning the `master` branch of the git repository.
## Option A: Download a Release
Download the [latest stable release](https://github.com/digitalocean/netbox/releases) from GitHub as a tarball or ZIP archive. Extract it to your desired path. In this example, we'll use `/opt/netbox`.
Download and extract the latest version:
```
# wget https://github.com/digitalocean/netbox/archive/vX.Y.Z.tar.gz
# tar -xzf vX.Y.Z.tar.gz -C /opt
# cd /opt/
# ln -sf netbox-X.Y.Z/ netbox
```
Copy the 'configuration.py' you created when first installing to the new version:
```
# cp /opt/netbox-X.Y.Z/configuration.py /opt/netbox/configuration.py
```
If you followed the original installation guide to set up gunicorn, be sure to copy its configuration as well:
```
# cp /opt/netbox-X.Y.Z/gunicorn_config.py /opt/netbox/gunicorn_config.py
```
## Option B: Clone the Git Repository (latest master release)
This guide assumes that NetBox is installed at `/opt/netbox`. Pull down the most recent iteration of the master branch:
```
# cd /opt/netbox
# git checkout master
# git pull origin master
# git status
```
# Run the Upgrade Script
Once the new code is in place, run the upgrade script (which may need to be run as root depending on how your environment is configured).
```
# ./upgrade.sh
```
This script:
* Installs or upgrades any new required Python packages
* Applies any database migrations that were included in the release
* Collects all static files to be served by the HTTP service
# Restart the WSGI Service
Finally, restart the WSGI service to run the new code. If you followed this guide for the initial installation, this is done using `supervisorctl`:
```
# sudo supervisorctl restart netbox
```

View File

@@ -0,0 +1,132 @@
# Web Server Installation
We'll set up a simple WSGI front end using [gunicorn](http://gunicorn.org/) for the purposes of this guide. For web servers, we provide example configurations for both [nginx](https://www.nginx.com/resources/wiki/) and [Apache](http://httpd.apache.org/docs/2.4). (You are of course free to use whichever combination of HTTP and WSGI services you'd like.) We'll also use [supervisord](http://supervisord.org/) to enable service persistence.
```
# sudo apt-get install -y gunicorn supervisor
```
## Option A: nginx
The following will serve as a minimal nginx configuration. Be sure to modify your server name and installation path appropriately.
```
# sudo apt-get install -y nginx
```
Once nginx is installed, proceed with the following configuration:
```
server {
listen 80;
server_name netbox.example.com;
access_log off;
location /static/ {
alias /opt/netbox/netbox/static/;
}
location / {
proxy_pass http://127.0.0.1:8001;
proxy_set_header X-Forwarded-Host $server_name;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
add_header P3P 'CP="ALL DSP COR PSAa PSDa OUR NOR ONL UNI COM NAV"';
}
}
```
Save this configuration to `/etc/nginx/sites-available/netbox`. Then, delete `/etc/nginx/sites-enabled/default` and create a symlink in the `sites-enabled` directory to the configuration file you just created.
```
# cd /etc/nginx/sites-enabled/
# rm default
# ln -s /etc/nginx/sites-available/netbox
```
Restart the nginx service to use the new configuration.
```
# service nginx restart
* Restarting nginx nginx
```
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).
## Option B: Apache
```
# sudo apt-get install -y apache2
```
Once Apache is installed, proceed with the following configuration (Be sure to modify the `ServerName` appropriately):
```
<VirtualHost *:80>
ProxyPreserveHost On
ServerName netbox.example.com
Alias /static /opt/netbox/netbox/static
<Directory /opt/netbox/netbox/static>
Options Indexes FollowSymLinks MultiViews
AllowOverride None
Require all granted
</Directory>
<Location /static>
ProxyPass !
</Location>
ProxyPass / http://127.0.0.1:8001/
ProxyPassReverse / http://127.0.0.1:8001/
</VirtualHost>
```
Save the contents of the above example in `/etc/apache2/sites-available/netbox.conf`, enable the `proxy` and `proxy_http` modules, and reload Apache:
```
# a2enmod proxy
# a2enmod proxy_http
# a2ensite netbox
# 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).
# gunicorn Installation
Save the following configuration file in the root netbox installation path (in this example, `/opt/netbox/`) as `gunicorn_config.py`. Be sure to verify the location of the gunicorn executable (e.g. `which gunicorn`) and to update the `pythonpath` variable if needed.
```
command = '/usr/bin/gunicorn'
pythonpath = '/opt/netbox/netbox'
bind = '127.0.0.1:8001'
workers = 3
user = 'www-data'
```
# supervisord Installation
Save the following as `/etc/supervisor/conf.d/netbox.conf`. Update the `command` and `directory` paths as needed.
```
[program:netbox]
command = gunicorn -c /opt/netbox/gunicorn_config.py netbox.wsgi
directory = /opt/netbox/netbox/
user = www-data
```
Finally, restart the supervisor service to detect and run the gunicorn service:
```
# service supervisor restart
```
At this point, you should be able to connect to the nginx HTTP service at the server name or IP address you provided. If you are unable to connect, check that the nginx service is running and properly configured. If you receive a 502 (bad gateway) error, this indicates that gunicorn is misconfigured or not running.
!!! info
Please keep in mind that the configurations provided here are bare minimums required to get NetBox up and running. You will almost certainly want to make some changes to better suit your production environment.

BIN
docs/media/screenshot1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

BIN
docs/media/screenshot2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

BIN
docs/media/screenshot3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

File diff suppressed because it is too large Load Diff

24
mkdocs.yml Normal file
View File

@@ -0,0 +1,24 @@
site_name: NetBox
pages:
- 'Introduction': 'index.md'
- 'Installation':
- 'PostgreSQL': 'installation/postgresql.md'
- 'NetBox': 'installation/netbox.md'
- 'Web Server': 'installation/web-server.md'
- 'LDAP (Optional)': 'installation/ldap.md'
- 'Upgrading': 'installation/upgrading.md'
- 'Alternate Install: Docker': 'installation/docker.md'
- 'Configuration':
- 'Mandatory Settings': 'configuration/mandatory-settings.md'
- 'Optional Settings': 'configuration/optional-settings.md'
- 'Data Model':
- 'Circuits': 'data-model/circuits.md'
- 'DCIM': 'data-model/dcim.md'
- 'IPAM': 'data-model/ipam.md'
- 'Secrets': 'data-model/secrets.md'
- 'Extras': 'data-model/extras.md'
- 'API Integration': 'api-integration.md'
markdown_extensions:
- admonition:

View File

@@ -1,9 +1,40 @@
import django_filters
from django.db.models import Q
from dcim.models import Site
from .models import Provider, Circuit, CircuitType
class ProviderFilter(django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
label='Search',
)
site_id = django_filters.ModelMultipleChoiceFilter(
name='circuits__site',
queryset=Site.objects.all(),
label='Site',
)
site = django_filters.ModelMultipleChoiceFilter(
name='circuits__site',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site (slug)',
)
class Meta:
model = Provider
fields = ['q', 'name', 'account', 'asn']
def search(self, queryset, value):
value = value.strip()
return queryset.filter(
Q(name__icontains=value) |
Q(account__icontains=value)
)
class CircuitFilter(django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',

View File

@@ -59,6 +59,16 @@ class ProviderBulkDeleteForm(ConfirmationForm):
pk = forms.ModelMultipleChoiceField(queryset=Provider.objects.all(), widget=forms.MultipleHiddenInput)
def provider_site_choices():
site_choices = Site.objects.all()
return [(s.slug, s.name) for s in site_choices]
class ProviderFilterForm(forms.Form, BootstrapMixin):
site = forms.MultipleChoiceField(required=False, choices=provider_site_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
#
# Circuit types
#

View File

@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.7 on 2016-07-13 19:24
from __future__ import unicode_literals
import dcim.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('circuits', '0002_auto_20160622_1821'),
]
operations = [
migrations.AlterField(
model_name='provider',
name='asn',
field=dcim.fields.ASNField(blank=True, null=True, verbose_name=b'ASN'),
),
]

View File

@@ -1,6 +1,7 @@
from django.core.urlresolvers import reverse
from django.db import models
from dcim.fields import ASNField
from dcim.models import Site, Interface
from utilities.models import CreatedUpdatedModel
@@ -12,7 +13,7 @@ class Provider(CreatedUpdatedModel):
"""
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
asn = models.PositiveIntegerField(blank=True, null=True, verbose_name='ASN')
asn = ASNField(blank=True, null=True, verbose_name='ASN')
account = models.CharField(max_length=30, blank=True, verbose_name='Account number')
portal_url = models.URLField(blank=True, verbose_name='Portal')
noc_contact = models.TextField(blank=True, verbose_name='NOC contact')

View File

@@ -16,6 +16,8 @@ from .models import Circuit, CircuitType, Provider
class ProviderListView(ObjectListView):
queryset = Provider.objects.annotate(count_circuits=Count('circuits'))
filter = filters.ProviderFilter
filter_form = forms.ProviderFilterForm
table = tables.ProviderTable
edit_permissions = ['circuits.change_provider', 'circuits.delete_provider']
template_name = 'circuits/provider_list.html'

View File

@@ -1 +1 @@
default_app_config = 'dcim.apps.IPAMConfig'
default_app_config = 'dcim.apps.DCIMConfig'

View File

@@ -2,9 +2,9 @@ from django.contrib import admin
from django.db.models import Count
from .models import (
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType,
Interface, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort,
PowerPortTemplate, Rack, RackGroup, Site,
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, Manufacturer, Module, Platform,
PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, Site,
)
@@ -61,6 +61,10 @@ class InterfaceTemplateAdmin(admin.TabularInline):
model = InterfaceTemplate
class DeviceBayTemplateAdmin(admin.TabularInline):
model = DeviceBayTemplate
@admin.register(DeviceType)
class DeviceTypeAdmin(admin.ModelAdmin):
prepopulated_fields = {
@@ -72,9 +76,10 @@ class DeviceTypeAdmin(admin.ModelAdmin):
PowerPortTemplateAdmin,
PowerOutletTemplateAdmin,
InterfaceTemplateAdmin,
DeviceBayTemplateAdmin,
]
list_display = ['model', 'manufacturer', 'slug', 'u_height', 'console_ports', 'console_server_ports', 'power_ports',
'power_outlets', 'interfaces']
'power_outlets', 'interfaces', 'device_bays']
list_filter = ['manufacturer']
def get_queryset(self, request):
@@ -84,6 +89,7 @@ class DeviceTypeAdmin(admin.ModelAdmin):
power_port_count=Count('power_port_templates', distinct=True),
power_outlet_count=Count('power_outlet_templates', distinct=True),
interface_count=Count('interface_templates', distinct=True),
devicebay_count=Count('device_bay_templates', distinct=True),
)
def console_ports(self, instance):
@@ -101,6 +107,9 @@ class DeviceTypeAdmin(admin.ModelAdmin):
def interfaces(self, instance):
return instance.interface_count
def device_bays(self, instance):
return instance.devicebay_count
#
# Devices
@@ -144,6 +153,12 @@ class InterfaceAdmin(admin.TabularInline):
model = Interface
class DeviceBayAdmin(admin.TabularInline):
model = DeviceBay
fk_name = 'device'
readonly_fields = ['installed_device']
class ModuleAdmin(admin.TabularInline):
model = Module
readonly_fields = ['parent', 'discovered']
@@ -157,6 +172,7 @@ class DeviceAdmin(admin.ModelAdmin):
PowerPortAdmin,
PowerOutletAdmin,
InterfaceAdmin,
DeviceBayAdmin,
ModuleAdmin,
]
list_display = ['display_name', 'device_type', 'device_role', 'primary_ip', 'rack', 'position', 'serial']
@@ -164,4 +180,4 @@ class DeviceAdmin(admin.ModelAdmin):
def get_queryset(self, request):
qs = super(DeviceAdmin, self).get_queryset(request)
return qs.select_related('device_type__manufacturer', 'device_role', 'primary_ip', 'rack')
return qs.select_related('device_type__manufacturer', 'device_role', 'primary_ip4', 'primary_ip6', 'rack')

View File

@@ -2,9 +2,9 @@ from rest_framework import serializers
from ipam.models import IPAddress
from dcim.models import (
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceType, DeviceRole,
Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Platform, PowerOutlet, PowerOutletTemplate,
PowerPort, PowerPortTemplate, Rack, RackGroup, RACK_FACE_FRONT, RACK_FACE_REAR, Site,
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceType,
DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Platform, PowerOutlet,
PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RACK_FACE_FRONT, RACK_FACE_REAR, Site,
)
@@ -221,16 +221,33 @@ class DeviceSerializer(serializers.ModelSerializer):
platform = PlatformNestedSerializer()
rack = RackNestedSerializer()
primary_ip = DeviceIPAddressNestedSerializer()
primary_ip4 = DeviceIPAddressNestedSerializer()
primary_ip6 = DeviceIPAddressNestedSerializer()
parent_device = serializers.SerializerMethodField()
class Meta:
model = Device
fields = ['id', 'name', 'display_name', 'device_type', 'device_role', 'platform', 'serial', 'rack', 'position',
'face', 'status', 'primary_ip', 'comments']
'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6', 'comments']
def get_parent_device(self, obj):
try:
device_bay = obj.parent_bay
except DeviceBay.DoesNotExist:
return None
return {
'id': device_bay.device.pk,
'name': device_bay.device.name,
'device_bay': {
'id': device_bay.pk,
'name': device_bay.name,
}
}
class DeviceNestedSerializer(DeviceSerializer):
class DeviceNestedSerializer(serializers.ModelSerializer):
class Meta(DeviceSerializer.Meta):
class Meta:
model = Device
fields = ['id', 'name', 'display_name']
@@ -319,7 +336,7 @@ class InterfaceSerializer(serializers.ModelSerializer):
class Meta:
model = Interface
fields = ['id', 'device', 'name', 'form_factor', 'mgmt_only', 'description', 'is_connected']
fields = ['id', 'device', 'name', 'form_factor', 'mac_address', 'mgmt_only', 'description', 'is_connected']
class InterfaceNestedSerializer(InterfaceSerializer):
@@ -333,10 +350,36 @@ class InterfaceDetailSerializer(InterfaceSerializer):
connected_interface = InterfaceSerializer(source='get_connected_interface')
class Meta(InterfaceSerializer.Meta):
fields = ['id', 'device', 'name', 'form_factor', 'mgmt_only', 'description', 'is_connected',
fields = ['id', 'device', 'name', 'form_factor', 'mac_address', 'mgmt_only', 'description', 'is_connected',
'connected_interface']
#
# Device bays
#
class DeviceBaySerializer(serializers.ModelSerializer):
device = DeviceNestedSerializer()
class Meta:
model = DeviceBay
fields = ['id', 'device', 'name']
class DeviceBayNestedSerializer(DeviceBaySerializer):
installed_device = DeviceNestedSerializer()
class Meta(DeviceBaySerializer.Meta):
fields = ['id', 'name', 'installed_device']
class DeviceBayDetailSerializer(DeviceBaySerializer):
installed_device = DeviceNestedSerializer()
class Meta(DeviceBaySerializer.Meta):
fields = ['id', 'device', 'name', 'installed_device']
#
# Interface connections
#

View File

@@ -49,6 +49,7 @@ urlpatterns = [
url(r'^devices/(?P<pk>\d+)/power-ports/$', PowerPortListView.as_view(), name='device_powerports'),
url(r'^devices/(?P<pk>\d+)/power-outlets/$', PowerOutletListView.as_view(), name='device_poweroutlets'),
url(r'^devices/(?P<pk>\d+)/interfaces/$', InterfaceListView.as_view(), name='device_interfaces'),
url(r'^devices/(?P<pk>\d+)/device-bays/$', DeviceBayListView.as_view(), name='device_devicebays'),
# Console ports
url(r'^console-ports/(?P<pk>\d+)/$', ConsolePortView.as_view(), name='consoleport'),

View File

@@ -9,8 +9,8 @@ from django.http import Http404
from django.shortcuts import get_object_or_404
from dcim.models import (
ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, IFACE_FF_VIRTUAL, Interface, InterfaceConnection,
Manufacturer, Platform, PowerOutlet, PowerPort, Rack, RackGroup, Site,
ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, DeviceType, IFACE_FF_VIRTUAL, Interface,
InterfaceConnection, Manufacturer, Platform, PowerOutlet, PowerPort, Rack, RackGroup, Site,
)
from dcim import filters
from .exceptions import MissingFilterException
@@ -194,7 +194,7 @@ class DeviceListView(generics.ListAPIView):
List devices (filterable)
"""
queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'platform', 'rack__site')\
.prefetch_related('primary_ip__nat_outside')
.prefetch_related('primary_ip4__nat_outside', 'primary_ip6__nat_outside')
serializer_class = serializers.DeviceSerializer
filter_class = filters.DeviceFilter
renderer_classes = api_settings.DEFAULT_RENDERER_CLASSES + [BINDZoneRenderer, FlatJSONRenderer]
@@ -326,6 +326,33 @@ class InterfaceConnectionView(generics.RetrieveUpdateDestroyAPIView):
queryset = InterfaceConnection.objects.all()
#
# Device bays
#
class DeviceBayListView(generics.ListAPIView):
"""
List device bays (by device)
"""
serializer_class = serializers.DeviceBayNestedSerializer
def get_queryset(self):
device = get_object_or_404(Device, pk=self.kwargs['pk'])
queryset = DeviceBay.objects.filter(device=device).select_related('installed_device')
# Filter by type (physical or virtual)
iface_type = self.request.query_params.get('type')
if iface_type == 'physical':
queryset = queryset.exclude(form_factor=IFACE_FF_VIRTUAL)
elif iface_type == 'virtual':
queryset = queryset.filter(form_factor=IFACE_FF_VIRTUAL)
elif iface_type is not None:
queryset = queryset.empty()
return queryset
#
# Live queries
#

View File

@@ -1,6 +1,6 @@
from django.apps import AppConfig
class IPAMConfig(AppConfig):
class DCIMConfig(AppConfig):
name = "dcim"
verbose_name = "DCIM"

53
netbox/dcim/fields.py Normal file
View File

@@ -0,0 +1,53 @@
from netaddr import EUI, mac_unix_expanded
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator, MaxValueValidator
from django.db import models
from .formfields import MACAddressFormField
class ASNField(models.BigIntegerField):
description = "32-bit ASN field"
default_validators = [
MinValueValidator(1),
MaxValueValidator(4294967295),
]
class mac_unix_expanded_uppercase(mac_unix_expanded):
word_fmt = '%.2X'
class MACAddressField(models.Field):
description = "PostgreSQL MAC Address field"
def python_type(self):
return EUI
def from_db_value(self, value, expression, connection, context):
return self.to_python(value)
def to_python(self, value):
if value is None:
return value
try:
return EUI(value, version=48, dialect=mac_unix_expanded_uppercase)
except ValueError as e:
raise ValidationError(e)
def db_type(self, connection):
return 'macaddr'
def get_prep_value(self, value):
if not value:
return None
return str(self.to_python(value))
def form_class(self):
return MACAddressFormField
def formfield(self, **kwargs):
defaults = {'form_class': self.form_class()}
defaults.update(kwargs)
return super(MACAddressField, self).formfield(**defaults)

View File

@@ -8,6 +8,27 @@ from .models import (
)
class SiteFilter(django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
label='Search',
)
class Meta:
model = Site
fields = ['q', 'name', 'facility', 'asn']
def search(self, queryset, value):
value = value.strip()
qs_filter = Q(name__icontains=value) | Q(facility__icontains=value) | Q(physical_address__icontains=value) | \
Q(shipping_address__icontains=value)
try:
qs_filter |= Q(asn=int(value))
except ValueError:
pass
return queryset.filter(qs_filter)
class RackGroupFilter(django_filters.FilterSet):
site_id = django_filters.ModelMultipleChoiceFilter(
name='site',
@@ -47,6 +68,12 @@ class RackFilter(django_filters.FilterSet):
queryset=RackGroup.objects.all(),
label='Group (ID)',
)
group = django_filters.ModelMultipleChoiceFilter(
name='group',
queryset=RackGroup.objects.all(),
to_field_name='slug',
label='Group',
)
class Meta:
model = Rack
@@ -95,6 +122,11 @@ class DeviceFilter(django_filters.FilterSet):
to_field_name='slug',
label='Site name (slug)',
)
rack_group_id = django_filters.ModelMultipleChoiceFilter(
name='rack__group',
queryset=RackGroup.objects.all(),
label='Rack group (ID)',
)
rack_id = django_filters.ModelMultipleChoiceFilter(
name='rack',
queryset=Rack.objects.all(),

View File

@@ -1919,7 +1919,8 @@
"position": 1,
"face": 0,
"status": true,
"primary_ip": 1,
"primary_ip4": 1,
"primary_ip6": null,
"comments": ""
}
},
@@ -1938,7 +1939,8 @@
"position": 17,
"face": 0,
"status": true,
"primary_ip": 5,
"primary_ip4": 5,
"primary_ip6": null,
"comments": ""
}
},
@@ -1957,7 +1959,8 @@
"position": 33,
"face": 0,
"status": true,
"primary_ip": null,
"primary_ip4": null,
"primary_ip6": null,
"comments": ""
}
},
@@ -1976,7 +1979,8 @@
"position": 34,
"face": 0,
"status": true,
"primary_ip": null,
"primary_ip4": null,
"primary_ip6": null,
"comments": ""
}
},
@@ -1995,7 +1999,8 @@
"position": 34,
"face": 0,
"status": true,
"primary_ip": null,
"primary_ip4": null,
"primary_ip6": null,
"comments": ""
}
},
@@ -2014,7 +2019,8 @@
"position": 33,
"face": 0,
"status": true,
"primary_ip": null,
"primary_ip4": null,
"primary_ip6": null,
"comments": ""
}
},
@@ -2033,7 +2039,8 @@
"position": 1,
"face": 0,
"status": true,
"primary_ip": 3,
"primary_ip4": 3,
"primary_ip6": null,
"comments": ""
}
},
@@ -2052,7 +2059,8 @@
"position": 17,
"face": 0,
"status": true,
"primary_ip": 19,
"primary_ip4": 19,
"primary_ip6": null,
"comments": ""
}
},
@@ -2071,7 +2079,8 @@
"position": 42,
"face": 0,
"status": true,
"primary_ip": null,
"primary_ip4": null,
"primary_ip6": null,
"comments": ""
}
},
@@ -2090,7 +2099,8 @@
"position": null,
"face": null,
"status": true,
"primary_ip": null,
"primary_ip4": null,
"primary_ip6": null,
"comments": ""
}
},
@@ -2109,7 +2119,8 @@
"position": null,
"face": null,
"status": true,
"primary_ip": null,
"primary_ip4": null,
"primary_ip6": null,
"comments": ""
}
},
@@ -3419,6 +3430,7 @@
"fields": {
"device": 3,
"name": "em0",
"mac_address": "00-00-00-AA-BB-CC",
"form_factor": 800,
"mgmt_only": true,
"description": ""
@@ -3772,6 +3784,7 @@
"device": 4,
"name": "em0",
"form_factor": 1000,
"mac_address": "ff-ee-dd-33-22-11",
"mgmt_only": true,
"description": ""
}
@@ -5686,6 +5699,7 @@
"device": 9,
"name": "eth0",
"form_factor": 1000,
"mac_address": "44-55-66-77-88-99",
"mgmt_only": true,
"description": ""
}

26
netbox/dcim/formfields.py Normal file
View File

@@ -0,0 +1,26 @@
from netaddr import EUI, AddrFormatError
from django import forms
from django.core.exceptions import ValidationError
#
# Form fields
#
class MACAddressFormField(forms.Field):
default_error_messages = {
'invalid': "Enter a valid MAC address.",
}
def to_python(self, value):
if not value:
return None
if isinstance(value, EUI):
return value
try:
return EUI(value, version=48)
except AddrFormatError:
raise ValidationError("Please specify a valid MAC address.")

View File

@@ -10,10 +10,10 @@ from utilities.forms import (
)
from .models import (
CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_PLANNED, CONNECTION_STATUS_CONNECTED, ConsolePort, ConsolePortTemplate,
ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType, Interface, IFACE_FF_VIRTUAL,
InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort,
PowerPortTemplate, Rack, RackGroup, Site, STATUS_CHOICES
DeviceBay, DeviceBayTemplate, CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_PLANNED, CONNECTION_STATUS_CONNECTED,
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType,
Interface, IFACE_FF_VIRTUAL, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet,
PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD
)
@@ -138,24 +138,23 @@ class RackForm(forms.ModelForm, BootstrapMixin):
class RackFromCSVForm(forms.ModelForm):
site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Site not found.'})
group = forms.ModelChoiceField(queryset=RackGroup.objects.all(), required=False, to_field_name='name',
error_messages={'invalid_choice': 'Group not found.'})
group_name = forms.CharField(required=False)
class Meta:
model = Rack
fields = ['site', 'group', 'name', 'facility_id', 'u_height']
fields = ['site', 'group_name', 'name', 'facility_id', 'u_height']
def clean(self):
site = self.cleaned_data.get('site')
group = self.cleaned_data.get('group')
group = self.cleaned_data.get('group_name')
# Validate device type
# Validate rack group
if site and group:
try:
self.instance.group = RackGroup.objects.get(site=site, name=group)
except RackGroup.DoesNotExist:
self.add_error('group', "Invalid rack group ({})".format(group))
self.add_error('group_name', "Invalid rack group ({})".format(group))
class RackImportForm(BulkImportForm, BootstrapMixin):
@@ -187,7 +186,7 @@ def rack_group_choices():
class RackFilterForm(forms.Form, BootstrapMixin):
site = forms.MultipleChoiceField(required=False, choices=rack_site_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
group_id = forms.MultipleChoiceField(required=False, choices=rack_group_choices,
group_id = forms.MultipleChoiceField(required=False, choices=rack_group_choices, label='Rack Group',
widget=forms.SelectMultiple(attrs={'size': 8}))
@@ -217,7 +216,7 @@ class DeviceTypeForm(forms.ModelForm, BootstrapMixin):
class Meta:
model = DeviceType
fields = ['manufacturer', 'model', 'slug', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu',
'is_network_device']
'is_network_device', 'subdevice_role']
class DeviceTypeBulkEditForm(forms.Form, BootstrapMixin):
@@ -284,6 +283,14 @@ class InterfaceTemplateForm(forms.ModelForm, BootstrapMixin):
fields = ['name_pattern', 'form_factor', 'mgmt_only']
class DeviceBayTemplateForm(forms.ModelForm, BootstrapMixin):
name_pattern = ExpandableNameField(label='Name')
class Meta:
model = DeviceBayTemplate
fields = ['name_pattern']
#
# Device roles
#
@@ -327,13 +334,13 @@ class DeviceForm(forms.ModelForm, BootstrapMixin):
display_field='display_name',
attrs={'filter-for': 'position'}
))
position = forms.TypedChoiceField(required=False, empty_value=None, widget=APISelect(
api_url='/api/dcim/racks/{{rack}}/rack-units/?face={{face}}',
disabled_indicator='device',
))
position = forms.TypedChoiceField(required=False, empty_value=None,
help_text="For multi-U devices, this is the lowest occupied rack unit.",
widget=APISelect(api_url='/api/dcim/racks/{{rack}}/rack-units/?face={{face}}',
disabled_indicator='device'))
manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(),
widget=forms.Select(attrs={'filter-for': 'device_type'}))
device_type = forms.ModelChoiceField(queryset=DeviceType.objects.all(), label='Model', widget=APISelect(
device_type = forms.ModelChoiceField(queryset=DeviceType.objects.all(), label='Device type', widget=APISelect(
api_url='/api/dcim/device-types/?manufacturer_id={{manufacturer}}',
display_field='model'
))
@@ -342,7 +349,7 @@ class DeviceForm(forms.ModelForm, BootstrapMixin):
class Meta:
model = Device
fields = ['name', 'device_role', 'device_type', 'serial', 'site', 'rack', 'position', 'face', 'status',
'platform', 'primary_ip', 'comments']
'platform', 'primary_ip4', 'primary_ip6', 'comments']
help_texts = {
'device_role': "The function this device serves",
'serial': "Chassis serial number",
@@ -362,20 +369,23 @@ class DeviceForm(forms.ModelForm, BootstrapMixin):
self.initial['site'] = self.instance.rack.site
self.initial['manufacturer'] = self.instance.device_type.manufacturer
# Compile list of IPs assigned to this device
primary_ip_choices = []
interface_ips = IPAddress.objects.filter(interface__device=self.instance)
primary_ip_choices += [(ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips]
nat_ips = IPAddress.objects.filter(nat_inside__interface__device=self.instance)\
# 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')
primary_ip_choices += [(ip.id, '{} ({} NAT)'.format(ip.address, ip.nat_inside.interface)) for ip in nat_ips]
self.fields['primary_ip'].choices = [(None, '---------')] + primary_ip_choices
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
else:
# An object that doesn't exist yet can't have any IPs assigned to it
self.fields['primary_ip'].choices = []
self.fields['primary_ip'].widget.attrs['readonly'] = True
self.fields['primary_ip4'].choices = []
self.fields['primary_ip4'].widget.attrs['readonly'] = True
self.fields['primary_ip6'].choices = []
self.fields['primary_ip6'].widget.attrs['readonly'] = True
# Limit rack choices
if self.is_bound:
@@ -387,10 +397,13 @@ class DeviceForm(forms.ModelForm, BootstrapMixin):
# Rack position
try:
pk = self.instance.pk if self.instance.pk else None
if self.is_bound and self.data.get('rack') and str(self.data.get('face')):
position_choices = Rack.objects.get(pk=self.data['rack']).get_rack_units(face=self.data.get('face'))
position_choices = Rack.objects.get(pk=self.data['rack'])\
.get_rack_units(face=self.data.get('face'), exclude=pk)
elif self.initial.get('rack') and str(self.initial.get('face')):
position_choices = Rack.objects.get(pk=self.initial['rack']).get_rack_units(face=self.initial.get('face'))
position_choices = Rack.objects.get(pk=self.initial['rack'])\
.get_rack_units(face=self.initial.get('face'), exclude=pk)
else:
position_choices = []
except Rack.DoesNotExist:
@@ -425,7 +438,7 @@ class DeviceFromCSVForm(forms.ModelForm):
'invalid_choice': 'Invalid site name.',
})
rack_name = forms.CharField()
face = forms.ChoiceField(choices=[('front', 'Front'), ('rear', 'Rear')])
face = forms.CharField(required=False)
class Meta:
model = Device
@@ -444,7 +457,7 @@ class DeviceFromCSVForm(forms.ModelForm):
try:
self.instance.device_type = DeviceType.objects.get(manufacturer=manufacturer, model=model_name)
except DeviceType.DoesNotExist:
self.add_error('model_name', "Invalid device type ({})".format(model_name))
self.add_error('model_name', "Invalid device type ({} {})".format(manufacturer, model_name))
# Validate rack
if site and rack_name:
@@ -455,11 +468,15 @@ class DeviceFromCSVForm(forms.ModelForm):
def clean_face(self):
face = self.cleaned_data['face']
if face.lower() == 'front':
return 0
if face.lower() == 'rear':
return 1
raise forms.ValidationError("Invalid rack face ({})".format(face))
if face:
try:
return {
'front': 0,
'rear': 1,
}[face.lower()]
except KeyError:
raise forms.ValidationError('Invalid rack face ({}); must be "front" or "rear".'.format(face))
return face
class DeviceImportForm(BulkImportForm, BootstrapMixin):
@@ -485,6 +502,11 @@ def device_site_choices():
return [(s.slug, '{} ({})'.format(s.name, s.device_count)) for s in site_choices]
def device_rack_group_choices():
group_choices = RackGroup.objects.select_related('site').annotate(device_count=Count('racks__devices'))
return [(g.pk, '{} ({})'.format(g, g.device_count)) for g in group_choices]
def device_role_choices():
role_choices = DeviceRole.objects.annotate(device_count=Count('devices'))
return [(r.slug, '{} ({})'.format(r.name, r.device_count)) for r in role_choices]
@@ -503,6 +525,8 @@ def device_platform_choices():
class DeviceFilterForm(forms.Form, BootstrapMixin):
site = forms.MultipleChoiceField(required=False, choices=device_site_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
rack_group_id = forms.MultipleChoiceField(required=False, choices=device_rack_group_choices, label='Rack Group',
widget=forms.SelectMultiple(attrs={'size': 8}))
role = forms.MultipleChoiceField(required=False, choices=device_role_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
device_type_id = forms.MultipleChoiceField(required=False, choices=device_type_choices, label='Type',
@@ -911,7 +935,7 @@ class InterfaceForm(forms.ModelForm, BootstrapMixin):
class Meta:
model = Interface
fields = ['device', 'name', 'form_factor', 'mgmt_only', 'description']
fields = ['device', 'name', 'form_factor', 'mac_address', 'mgmt_only', 'description']
widgets = {
'device': forms.HiddenInput(),
}
@@ -922,7 +946,7 @@ class InterfaceCreateForm(forms.ModelForm, BootstrapMixin):
class Meta:
model = Interface
fields = ['name_pattern', 'form_factor', 'mgmt_only', 'description']
fields = ['name_pattern', 'form_factor', 'mac_address', 'mgmt_only', 'description']
class InterfaceBulkCreateForm(InterfaceCreateForm, BootstrapMixin):
@@ -1037,20 +1061,29 @@ class InterfaceConnectionImportForm(BulkImportForm, BootstrapMixin):
return
connection_list = []
occupied_interfaces = []
for i, record in enumerate(records, start=1):
form = self.fields['csv'].csv_form(data=record)
if form.is_valid():
interface_a = Interface.objects.get(device=form.cleaned_data['device_a'],
name=form.cleaned_data['interface_a'])
if interface_a in occupied_interfaces:
raise forms.ValidationError("{} {} found in multiple connections"
.format(interface_a.device.name, interface_a.name))
interface_b = Interface.objects.get(device=form.cleaned_data['device_b'],
name=form.cleaned_data['interface_b'])
if interface_b in occupied_interfaces:
raise forms.ValidationError("{} {} found in multiple connections"
.format(interface_b.device.name, interface_b.name))
connection = InterfaceConnection(interface_a=interface_a, interface_b=interface_b)
if form.cleaned_data['status'] == 'planned':
connection.connection_status = CONNECTION_STATUS_PLANNED
else:
connection.connection_status = CONNECTION_STATUS_CONNECTED
connection_list.append(connection)
occupied_interfaces.append(interface_a)
occupied_interfaces.append(interface_b)
else:
for field, errors in form.errors.items():
for e in errors:
@@ -1065,6 +1098,41 @@ class InterfaceConnectionDeletionForm(forms.Form, BootstrapMixin):
device = forms.ModelChoiceField(queryset=Device.objects.all(), widget=forms.HiddenInput(), required=False)
#
# Device bays
#
class DeviceBayForm(forms.ModelForm, BootstrapMixin):
class Meta:
model = DeviceBay
fields = ['device', 'name']
widgets = {
'device': forms.HiddenInput(),
}
class DeviceBayCreateForm(forms.Form, BootstrapMixin):
name_pattern = ExpandableNameField(label='Name')
class PopulateDeviceBayForm(forms.Form, BootstrapMixin):
installed_device = forms.ModelChoiceField(queryset=Device.objects.all(), label='Child Device',
help_text="Child devices must first be created within the rack occupied "
"by the parent device. Then they can be assigned to a bay.")
def __init__(self, device_bay, *args, **kwargs):
super(PopulateDeviceBayForm, self).__init__(*args, **kwargs)
children_queryset = Device.objects.filter(rack=device_bay.device.rack,
parent_bay__isnull=True,
device_type__u_height=0,
device_type__subdevice_role=SUBDEVICE_ROLE_CHILD)\
.exclude(pk=device_bay.device.pk)
self.fields['installed_device'].queryset = children_queryset
#
# Connections
#

View File

@@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.7 on 2016-06-28 17:21
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0002_auto_20160622_1821'),
]
operations = [
migrations.AlterField(
model_name='interface',
name='form_factor',
field=models.PositiveSmallIntegerField(choices=[[0, b'Virtual'], [800, b'10/100M (100BASE-TX)'], [1000, b'1GE (1000BASE-T)'], [1100, b'1GE (SFP)'], [1150, b'10GE (10GBASE-T)'], [1200, b'10GE (SFP+)'], [1300, b'10GE (XFP)'], [1400, b'40GE (QSFP+)']], default=1200),
),
migrations.AlterField(
model_name='interfacetemplate',
name='form_factor',
field=models.PositiveSmallIntegerField(choices=[[0, b'Virtual'], [800, b'10/100M (100BASE-TX)'], [1000, b'1GE (1000BASE-T)'], [1100, b'1GE (SFP)'], [1150, b'10GE (10GBASE-T)'], [1200, b'10GE (SFP+)'], [1300, b'10GE (XFP)'], [1400, b'40GE (QSFP+)']], default=1200),
),
]

View File

@@ -0,0 +1,56 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.7 on 2016-07-01 20:49
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('dcim', '0003_auto_20160628_1721'),
]
operations = [
migrations.CreateModel(
name='DeviceBay',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50, verbose_name=b'Name')),
('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='device_bays', to='dcim.Device')),
('installed_device', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='parent_bay', to='dcim.Device')),
],
options={
'ordering': ['device', 'name'],
},
),
migrations.CreateModel(
name='DeviceBayTemplate',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=30)),
],
options={
'ordering': ['device_type', 'name'],
},
),
migrations.AddField(
model_name='devicetype',
name='subdevice_role',
field=models.NullBooleanField(choices=[(None, b'N/A'), (True, b'Parent'), (False, b'Child')], default=None, help_text=b'Parent devices house child devices in device bays. Select "None" if this device type is neither a parent nor a child.', verbose_name=b'Parent/child status'),
),
migrations.AddField(
model_name='devicebaytemplate',
name='device_type',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='device_bay_templates', to='dcim.DeviceType'),
),
migrations.AlterUniqueTogether(
name='devicebaytemplate',
unique_together=set([('device_type', 'name')]),
),
migrations.AlterUniqueTogether(
name='devicebay',
unique_together=set([('device', 'name')]),
),
]

View File

@@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.7 on 2016-07-06 17:22
from __future__ import unicode_literals
import dcim.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0004_auto_20160701_2049'),
]
operations = [
migrations.AddField(
model_name='interface',
name='mac_address',
field=dcim.fields.MACAddressField(blank=True, null=True, verbose_name=b'MAC Address'),
),
migrations.AlterField(
model_name='devicetype',
name='subdevice_role',
field=models.NullBooleanField(choices=[(None, b'None'), (True, b'Parent'), (False, b'Child')], default=None, help_text=b'Parent devices house child devices in device bays. Select "None" if this device type is neither a parent nor a child.', verbose_name=b'Parent/child status'),
),
]

View File

@@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.7 on 2016-07-11 18:40
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('ipam', '0001_initial'),
('dcim', '0005_auto_20160706_1722'),
]
operations = [
migrations.AddField(
model_name='device',
name='primary_ip4',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='primary_ip4_for', to='ipam.IPAddress', verbose_name=b'Primary IPv4'),
),
migrations.AddField(
model_name='device',
name='primary_ip6',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='primary_ip6_for', to='ipam.IPAddress', verbose_name=b'Primary IPv6'),
),
]

View File

@@ -0,0 +1,41 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.7 on 2016-07-11 18:40
from __future__ import unicode_literals
from django.db import migrations
def copy_primary_ip(apps, schema_editor):
Device = apps.get_model('dcim', 'Device')
for d in Device.objects.select_related('primary_ip'):
if not d.primary_ip:
continue
if d.primary_ip.family == 4:
d.primary_ip4 = d.primary_ip
elif d.primary_ip.family == 6:
d.primary_ip6 = d.primary_ip
d.save()
def restore_primary_ip(apps, schema_editor):
Device = apps.get_model('dcim', 'Device')
for d in Device.objects.select_related('primary_ip4', 'primary_ip6'):
if d.primary_ip:
continue
# Prefer IPv6 over IPv4
if d.primary_ip6:
d.primary_ip = d.primary_ip6
elif d.primary_ip4:
d.primary_ip = d.primary_ip4
d.save()
class Migration(migrations.Migration):
dependencies = [
('dcim', '0006_add_device_primary_ip4_ip6'),
]
operations = [
migrations.RunPython(copy_primary_ip, restore_primary_ip),
]

View File

@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.7 on 2016-07-11 19:01
from __future__ import unicode_literals
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('dcim', '0007_device_copy_primary_ip'),
]
operations = [
migrations.RemoveField(
model_name='device',
name='primary_ip',
),
]

View File

@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.7 on 2016-07-13 19:24
from __future__ import unicode_literals
import dcim.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('dcim', '0008_device_remove_primary_ip'),
]
operations = [
migrations.AlterField(
model_name='site',
name='asn',
field=dcim.fields.ASNField(blank=True, null=True, verbose_name=b'ASN'),
),
]

View File

@@ -1,15 +1,17 @@
from collections import OrderedDict
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.urlresolvers import reverse
from django.core.validators import MinValueValidator
from django.db import models
from django.db.models import Q, ObjectDoesNotExist
from django.db.models import Count, Q, ObjectDoesNotExist
from extras.rpc import RPC_CLIENTS
from utilities.fields import NullableCharField
from utilities.models import CreatedUpdatedModel
from .fields import ASNField, MACAddressField
RACK_FACE_FRONT = 0
RACK_FACE_REAR = 1
@@ -18,6 +20,14 @@ RACK_FACE_CHOICES = [
[RACK_FACE_REAR, 'Rear'],
]
SUBDEVICE_ROLE_PARENT = True
SUBDEVICE_ROLE_CHILD = False
SUBDEVICE_ROLE_CHOICES = (
(None, 'None'),
(SUBDEVICE_ROLE_PARENT, 'Parent'),
(SUBDEVICE_ROLE_CHILD, 'Child'),
)
COLOR_TEAL = 'teal'
COLOR_GREEN = 'green'
COLOR_BLUE = 'blue'
@@ -45,14 +55,16 @@ IFACE_FF_VIRTUAL = 0
IFACE_FF_100M_COPPER = 800
IFACE_FF_1GE_COPPER = 1000
IFACE_FF_SFP = 1100
IFACE_FF_10GE_COPPER = 1150
IFACE_FF_SFP_PLUS = 1200
IFACE_FF_XFP = 1300
IFACE_FF_QSFP_PLUS = 1400
IFACE_FF_CHOICES = [
[IFACE_FF_VIRTUAL, 'Virtual'],
[IFACE_FF_100M_COPPER, '10/100M (Copper)'],
[IFACE_FF_1GE_COPPER, '1GE (Copper)'],
[IFACE_FF_100M_COPPER, '10/100M (100BASE-TX)'],
[IFACE_FF_1GE_COPPER, '1GE (1000BASE-T)'],
[IFACE_FF_SFP, '1GE (SFP)'],
[IFACE_FF_10GE_COPPER, '10GE (10GBASE-T)'],
[IFACE_FF_SFP_PLUS, '10GE (SFP+)'],
[IFACE_FF_XFP, '10GE (XFP)'],
[IFACE_FF_QSFP_PLUS, '40GE (QSFP+)'],
@@ -83,6 +95,48 @@ RPC_CLIENT_CHOICES = [
]
def order_interfaces(queryset, sql_col, primary_ordering=tuple()):
"""
Attempt to match interface names by their slot/position identifiers and order according. Matching is done using the
following pattern:
{a}/{b}/{c}:{d}
Interfaces are ordered first by field a, then b, then c, and finally d. Leading text (which typically indicates the
interface's type) is ignored. If any fields are not contained by an interface name, those fields are treated as
None. 'None' is ordered after all other values. For example:
et-0/0/0
et-0/0/1
et-0/1/0
xe-0/1/1:0
xe-0/1/1:1
xe-0/1/1:2
xe-0/1/1:3
et-0/1/2
...
et-0/1/9
et-0/1/10
et-0/1/11
et-1/0/0
et-1/0/1
...
vlan1
vlan10
:param queryset: The base queryset to be ordered
:param sql_col: Table and name of the SQL column which contains the interface name (ex: ''dcim_interface.name')
:param primary_ordering: A tuple of fields which take ordering precedence before the interface name (optional)
"""
ordering = primary_ordering + ('_id1', '_id2', '_id3', '_id4')
return queryset.extra(select={
'_id1': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+\/[0-9]+(:[0-9]+)?$') AS integer)".format(sql_col),
'_id2': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+(:[0-9]+)?$') AS integer)".format(sql_col),
'_id3': "CAST(SUBSTRING({} FROM '([0-9]+)(:[0-9]+)?$') AS integer)".format(sql_col),
'_id4': "CAST(SUBSTRING({} FROM ':([0-9]+)$') AS integer)".format(sql_col),
}).order_by(*ordering)
class Site(CreatedUpdatedModel):
"""
A Site represents a geographic location within a network; typically a building or campus. The optional facility
@@ -91,7 +145,7 @@ class Site(CreatedUpdatedModel):
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
facility = models.CharField(max_length=50, blank=True)
asn = models.PositiveIntegerField(blank=True, null=True, verbose_name='ASN')
asn = ASNField(blank=True, null=True, verbose_name='ASN')
physical_address = models.CharField(max_length=200, blank=True)
shipping_address = models.CharField(max_length=200, blank=True)
comments = models.TextField(blank=True)
@@ -183,6 +237,17 @@ class Rack(CreatedUpdatedModel):
def get_absolute_url(self):
return reverse('dcim:rack', args=[self.pk])
def clean(self):
# Validate that Rack is tall enough to house the installed Devices
if self.pk:
top_device = Device.objects.filter(rack=self).exclude(position__isnull=True).order_by('-position').first()
if top_device:
min_height = top_device.position + top_device.device_type.u_height - 1
if self.u_height < min_height:
raise ValidationError("Rack must be at least {}U tall with currently installed devices."
.format(min_height))
def to_csv(self):
return ','.join([
self.site.name,
@@ -199,15 +264,16 @@ class Rack(CreatedUpdatedModel):
@property
def display_name(self):
if self.facility_id:
return "{} ({})".format(self.name, self.facility_id)
return u"{} ({})".format(self.name, self.facility_id)
return self.name
def get_rack_units(self, face=RACK_FACE_FRONT, remove_redundant=False):
def get_rack_units(self, face=RACK_FACE_FRONT, exclude=None, remove_redundant=False):
"""
Return a list of rack units as dictionaries. Example: {'device': None, 'face': 0, 'id': 48, 'name': 'U48'}
Each key 'device' is either a Device or None. By default, multi-U devices are repeated for each U they occupy.
:param face: Rack face (front or rear)
:param exclude: PK of a Device to exclude (optional); helpful when relocating a Device within a Rack
:param remove_redundant: If True, rack units occupied by a device already listed will be omitted
"""
@@ -218,7 +284,10 @@ class Rack(CreatedUpdatedModel):
# Add devices to rack units list
if self.pk:
for device in Device.objects.select_related('device_type__manufacturer', 'device_role')\
.filter(rack=self, position__gt=0).filter(Q(face=face) | Q(device_type__is_full_depth=True)):
.annotate(devicebay_count=Count('device_bays'))\
.exclude(pk=exclude)\
.filter(rack=self, position__gt=0)\
.filter(Q(face=face) | Q(device_type__is_full_depth=True)):
if remove_redundant:
elevation[device.position]['device'] = device
for u in range(device.position + 1, device.position + device.device_type.u_height):
@@ -322,6 +391,10 @@ class DeviceType(models.Model):
help_text="This type of device has power outlets")
is_network_device = models.BooleanField(default=True, verbose_name='Is a network device',
help_text="This type of device has network interfaces")
subdevice_role = models.NullBooleanField(default=None, verbose_name='Parent/child status',
choices=SUBDEVICE_ROLE_CHOICES,
help_text="Parent devices house child devices in device bays. Select "
"\"None\" if this device type is neither a parent nor a child.")
class Meta:
ordering = ['manufacturer', 'model']
@@ -331,11 +404,40 @@ class DeviceType(models.Model):
]
def __unicode__(self):
return "{0} {1}".format(self.manufacturer, self.model)
return "{} {}".format(self.manufacturer, self.model)
def get_absolute_url(self):
return reverse('dcim:devicetype', args=[self.pk])
def clean(self):
if not self.is_console_server and self.cs_port_templates.count():
raise ValidationError("Must delete all console server port templates associated with this device before "
"declassifying it as a console server.")
if not self.is_pdu and self.power_outlet_templates.count():
raise ValidationError("Must delete all power outlet templates associated with this device before "
"declassifying it as a PDU.")
if not self.is_network_device and self.interface_templates.filter(mgmt_only=False).count():
raise ValidationError("Must delete all non-management-only interface templates associated with this device "
"before declassifying it as a network device.")
if self.subdevice_role != SUBDEVICE_ROLE_PARENT and self.device_bay_templates.count():
raise ValidationError("Must delete all device bay templates associated with this device before "
"declassifying it as a parent device.")
if self.u_height and self.subdevice_role == SUBDEVICE_ROLE_CHILD:
raise ValidationError("Child device types must be 0U.")
@property
def is_parent_device(self):
return bool(self.subdevice_role)
@property
def is_child_device(self):
return bool(self.subdevice_role is False)
class ConsolePortTemplate(models.Model):
"""
@@ -397,6 +499,13 @@ class PowerOutletTemplate(models.Model):
return self.name
class InterfaceTemplateManager(models.Manager):
def get_queryset(self):
qs = super(InterfaceTemplateManager, self).get_queryset()
return order_interfaces(qs, 'dcim_interfacetemplate.name', ('device_type',))
class InterfaceTemplate(models.Model):
"""
A template for a physical data interface on a new Device.
@@ -406,6 +515,23 @@ class InterfaceTemplate(models.Model):
form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_SFP_PLUS)
mgmt_only = models.BooleanField(default=False, verbose_name='Management only')
objects = InterfaceTemplateManager()
class Meta:
ordering = ['device_type', 'name']
unique_together = ['device_type', 'name']
def __unicode__(self):
return self.name
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)
class Meta:
ordering = ['device_type', 'name']
unique_together = ['device_type', 'name']
@@ -480,8 +606,10 @@ class Device(CreatedUpdatedModel):
help_text='Number of the lowest U position occupied by the device')
face = models.PositiveSmallIntegerField(blank=True, null=True, choices=RACK_FACE_CHOICES, verbose_name='Rack face')
status = models.BooleanField(choices=STATUS_CHOICES, default=STATUS_ACTIVE, verbose_name='Status')
primary_ip = models.OneToOneField('ipam.IPAddress', related_name='primary_for', on_delete=models.SET_NULL,
blank=True, null=True, verbose_name='Primary IP')
primary_ip4 = models.OneToOneField('ipam.IPAddress', related_name='primary_ip4_for', on_delete=models.SET_NULL,
blank=True, null=True, verbose_name='Primary IPv4')
primary_ip6 = models.OneToOneField('ipam.IPAddress', related_name='primary_ip6_for', on_delete=models.SET_NULL,
blank=True, null=True, verbose_name='Primary IPv6')
comments = models.TextField(blank=True)
class Meta:
@@ -496,12 +624,19 @@ class Device(CreatedUpdatedModel):
def clean(self):
# Child devices cannot be assigned to a rack face/unit
if self.device_type.is_child_device and (self.face is not None or self.position):
raise ValidationError("Child device types cannot be assigned a rack face or position.")
# Validate position/face combination
if self.position and self.face is None:
raise ValidationError("Must specify rack face with rack position.")
# Validate rack space
try:
rack_face = self.face if not self.device_type.is_full_depth else None
except DeviceType.DoesNotExist:
raise ValidationError("Must specify device type.")
exclude_list = [self.pk] if self.pk else []
try:
available_units = self.rack.get_available_units(u_height=self.device_type.u_height, rack_face=rack_face,
@@ -540,6 +675,10 @@ class Device(CreatedUpdatedModel):
[Interface(device=self, name=template.name, form_factor=template.form_factor,
mgmt_only=template.mgmt_only) for template in self.device_type.interface_templates.all()]
)
DeviceBay.objects.bulk_create(
[DeviceBay(device=self, name=template.name) for template in
self.device_type.device_bay_templates.all()]
)
def to_csv(self):
return ','.join([
@@ -560,9 +699,9 @@ class Device(CreatedUpdatedModel):
if self.name:
return self.name
elif self.position:
return "{} ({} U{})".format(self.device_type, self.rack.name, self.position)
return u"{} ({} U{})".format(self.device_type, self.rack.name, self.position)
else:
return "{} ({})".format(self.device_type, self.rack.name)
return u"{} ({})".format(self.device_type, self.rack.name)
@property
def identifier(self):
@@ -573,6 +712,23 @@ class Device(CreatedUpdatedModel):
return self.name
return '{{{}}}'.format(self.pk)
@property
def primary_ip(self):
if settings.PREFER_IPV4 and self.primary_ip4:
return self.primary_ip4
elif self.primary_ip6:
return self.primary_ip6
elif self.primary_ip4:
return self.primary_ip4
else:
return None
def get_children(self):
"""
Return the set of child Devices installed in DeviceBays within this Device.
"""
return Device.objects.filter(parent_bay__device=self.pk)
def get_rpc_client(self):
"""
Return the appropriate RPC (e.g. NETCONF, ssh, etc.) client for this device's platform, if one is defined.
@@ -697,18 +853,8 @@ class PowerOutlet(models.Model):
class InterfaceManager(models.Manager):
def get_queryset(self):
"""
Cast up to three interface slot/position IDs as independent integers and order appropriately. This ensures that
interfaces are ordered numerically without regard to type. For example:
xe-0/0/0, xe-0/0/1, xe-0/0/2 ... et-0/0/47, et-0/0/48, et-0/0/49 ...
instead of:
et-0/0/48, et-0/0/49, et-0/0/50 ... et-0/0/53, xe-0/0/0, xe-0/0/1 ...
"""
return super(InterfaceManager, self).get_queryset().extra(select={
'_id1': "CAST(SUBSTRING(dcim_interface.name FROM '([0-9]+)\/([0-9]+)\/([0-9]+)$') AS integer)",
'_id2': "CAST(SUBSTRING(dcim_interface.name FROM '([0-9]+)\/([0-9]+)$') AS integer)",
'_id3': "CAST(SUBSTRING(dcim_interface.name FROM '([0-9]+)$') AS integer)",
}).order_by('device', '_id1', '_id2', '_id3')
qs = super(InterfaceManager, self).get_queryset()
return order_interfaces(qs, 'dcim_interface.name', ('device',))
def virtual(self):
return self.get_queryset().filter(form_factor=IFACE_FF_VIRTUAL)
@@ -725,6 +871,7 @@ class Interface(models.Model):
device = models.ForeignKey('Device', related_name='interfaces', on_delete=models.CASCADE)
name = models.CharField(max_length=30)
form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_SFP_PLUS)
mac_address = MACAddressField(null=True, blank=True, verbose_name='MAC Address')
mgmt_only = models.BooleanField(default=False, verbose_name='OOB Management',
help_text="This interface is used only for out-of-band management")
description = models.CharField(max_length=100, blank=True)
@@ -800,6 +947,33 @@ class InterfaceConnection(models.Model):
])
class DeviceBay(models.Model):
"""
An empty space within a Device which can house a child device
"""
device = models.ForeignKey('Device', related_name='device_bays', on_delete=models.CASCADE)
name = models.CharField(max_length=50, verbose_name='Name')
installed_device = models.OneToOneField('Device', related_name='parent_bay', blank=True, null=True)
class Meta:
ordering = ['device', 'name']
unique_together = ['device', 'name']
def __unicode__(self):
return '{} - {}'.format(self.device.name, self.name)
def clean(self):
# Validate that the parent Device can have DeviceBays
if not self.device.device_type.is_parent_device:
raise ValidationError("This type of device ({}) does not support device bays."
.format(self.device.device_type))
# Cannot install a device into itself, obviously
if self.device == self.installed_device:
raise ValidationError("Cannot install a device into itself.")
class Module(models.Model):
"""
A Module represents a piece of hardware within a Device, such as a line card or power supply. Modules are used only

View File

@@ -4,8 +4,9 @@ from django_tables2.utils import Accessor
from utilities.tables import BaseTable, ToggleColumn
from .models import (
ConsolePort, ConsolePortTemplate, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType, InterfaceTemplate,
Manufacturer, Platform, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, Site,
ConsolePort, ConsolePortTemplate, ConsoleServerPortTemplate, Device, DeviceBayTemplate, DeviceRole, DeviceType,
Interface, InterfaceTemplate, Manufacturer, Platform, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack,
RackGroup, Site,
)
@@ -201,6 +202,19 @@ class InterfaceTemplateTable(tables.Table):
}
class DeviceBayTemplateTable(tables.Table):
pk = ToggleColumn()
class Meta:
model = DeviceBayTemplate
fields = ('pk', 'name')
empty_text = "None"
show_header = False
attrs = {
'class': 'table table-hover panel-body',
}
#
# Device roles
#
@@ -305,5 +319,5 @@ class InterfaceConnectionTable(BaseTable):
interface_b = tables.Column(verbose_name='Interface B')
class Meta(BaseTable.Meta):
model = PowerPort
model = Interface
fields = ('device_a', 'interface_a', 'device_b', 'interface_b')

View File

@@ -47,7 +47,7 @@ class SiteTest(APITestCase):
graph_fields = [
'name',
'embed_url',
'link',
'embed_link',
]
def test_get_list(self, endpoint='/api/dcim/sites/'):
@@ -315,8 +315,11 @@ class DeviceTest(APITestCase):
'rack',
'position',
'face',
'parent_device',
'status',
'primary_ip',
'primary_ip4',
'primary_ip6',
'comments',
]
@@ -366,6 +369,7 @@ class DeviceTest(APITestCase):
'face',
'id',
'name',
'parent_device',
'platform_id',
'platform_name',
'platform_slug',
@@ -373,6 +377,10 @@ class DeviceTest(APITestCase):
'primary_ip_address',
'primary_ip_family',
'primary_ip_id',
'primary_ip4_address',
'primary_ip4_family',
'primary_ip4_id',
'primary_ip6',
'rack_display_name',
'rack_facility_id',
'rack_id',
@@ -527,6 +535,7 @@ class InterfaceTest(APITestCase):
'device',
'name',
'form_factor',
'mac_address',
'mgmt_only',
'description',
'is_connected'
@@ -539,6 +548,7 @@ class InterfaceTest(APITestCase):
'device',
'name',
'form_factor',
'mac_address',
'mgmt_only',
'description',
'is_connected',

View File

@@ -4,7 +4,8 @@ from secrets.views import secret_add
from . import views
from .models import (
ConsolePortTemplate, ConsoleServerPortTemplate, PowerPortTemplate, PowerOutletTemplate, InterfaceTemplate,
ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, PowerPortTemplate, PowerOutletTemplate,
InterfaceTemplate,
)
@@ -70,6 +71,10 @@ urlpatterns = [
name='devicetype_add_interface'),
url(r'^device-types/(?P<pk>\d+)/interfaces/delete/$', views.component_template_delete,
{'model': InterfaceTemplate}, name='devicetype_delete_interface'),
url(r'^device-types/(?P<pk>\d+)/device-bays/add/$', views.DeviceBayTemplateAddView.as_view(),
name='devicetype_add_devicebay'),
url(r'^device-types/(?P<pk>\d+)/device-bays/delete/$', views.component_template_delete,
{'model': DeviceBayTemplate}, name='devicetype_delete_devicebay'),
# Device roles
url(r'^device-roles/$', views.DeviceRoleListView.as_view(), name='devicerole_list'),
@@ -125,6 +130,13 @@ urlpatterns = [
url(r'^power-outlets/(?P<pk>\d+)/edit/$', views.poweroutlet_edit, name='poweroutlet_edit'),
url(r'^power-outlets/(?P<pk>\d+)/delete/$', views.poweroutlet_delete, name='poweroutlet_delete'),
# Device bays
url(r'^devices/(?P<pk>\d+)/bays/add/$', views.devicebay_add, name='devicebay_add'),
url(r'^device-bays/(?P<pk>\d+)/edit/$', views.devicebay_edit, name='devicebay_edit'),
url(r'^device-bays/(?P<pk>\d+)/delete/$', views.devicebay_delete, name='devicebay_delete'),
url(r'^device-bays/(?P<pk>\d+)/populate/$', views.devicebay_populate, name='devicebay_populate'),
url(r'^device-bays/(?P<pk>\d+)/depopulate/$', views.devicebay_depopulate, name='devicebay_depopulate'),
# Console/power/interface connections
url(r'^console-connections/$', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'),
url(r'^console-connections/import/$', views.ConsoleConnectionsBulkImportView.as_view(), name='console_connections_import'),

View File

@@ -1,4 +1,6 @@
import re
from natsort import natsorted
from operator import attrgetter
from django.contrib import messages
from django.contrib.auth.decorators import permission_required
@@ -24,8 +26,9 @@ from utilities.views import (
from . import filters, forms, tables
from .models import (
CONNECTION_STATUS_CONNECTED, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device,
DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform,
PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, Site,
DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate,
Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
Site,
)
@@ -61,6 +64,7 @@ def expand_pattern(string):
class SiteListView(ObjectListView):
queryset = Site.objects.all()
filter = filters.SiteFilter
table = tables.SiteTable
template_name = 'dcim/site_list.html'
@@ -75,11 +79,13 @@ def site(request, slug):
'vlan_count': VLAN.objects.filter(site=site).count(),
'circuit_count': Circuit.objects.filter(site=site).count(),
}
rack_groups = RackGroup.objects.filter(site=site).annotate(rack_count=Count('racks'))
topology_maps = TopologyMap.objects.filter(site=site)
return render(request, 'dcim/site.html', {
'site': site,
'stats': stats,
'rack_groups': rack_groups,
'topology_maps': topology_maps,
})
@@ -150,7 +156,8 @@ def rack(request, pk):
rack = get_object_or_404(Rack, pk=pk)
nonracked_devices = Device.objects.filter(rack=rack, position__isnull=True)
nonracked_devices = Device.objects.filter(rack=rack, position__isnull=True)\
.select_related('device_type__manufacturer')
next_rack = Rack.objects.filter(site=rack.site, name__gt=rack.name).order_by('name').first()
prev_rack = Rack.objects.filter(site=rack.site, name__lt=rack.name).order_by('-name').first()
@@ -254,18 +261,33 @@ def devicetype(request, pk):
devicetype = get_object_or_404(DeviceType, pk=pk)
# Component tables
consoleport_table = tables.ConsolePortTemplateTable(ConsolePortTemplate.objects.filter(device_type=devicetype))
consoleserverport_table = tables.ConsoleServerPortTemplateTable(ConsoleServerPortTemplate.objects
.filter(device_type=devicetype))
powerport_table = tables.PowerPortTemplateTable(PowerPortTemplate.objects.filter(device_type=devicetype))
poweroutlet_table = tables.PowerOutletTemplateTable(PowerOutletTemplate.objects.filter(device_type=devicetype))
interface_table = tables.InterfaceTemplateTable(InterfaceTemplate.objects.filter(device_type=devicetype))
consoleport_table = tables.ConsolePortTemplateTable(
natsorted(ConsolePortTemplate.objects.filter(device_type=devicetype), key=attrgetter('name'))
)
consoleserverport_table = tables.ConsoleServerPortTemplateTable(
natsorted(ConsoleServerPortTemplate.objects.filter(device_type=devicetype), key=attrgetter('name'))
)
powerport_table = tables.PowerPortTemplateTable(
natsorted(PowerPortTemplate.objects.filter(device_type=devicetype), key=attrgetter('name'))
)
poweroutlet_table = tables.PowerOutletTemplateTable(
natsorted(PowerOutletTemplate.objects.filter(device_type=devicetype), key=attrgetter('name'))
)
mgmt_interface_table = tables.InterfaceTemplateTable(InterfaceTemplate.objects.filter(device_type=devicetype,
mgmt_only=True))
interface_table = tables.InterfaceTemplateTable(InterfaceTemplate.objects.filter(device_type=devicetype,
mgmt_only=False))
devicebay_table = tables.DeviceBayTemplateTable(
natsorted(DeviceBayTemplate.objects.filter(device_type=devicetype), key=attrgetter('name'))
)
if request.user.has_perm('dcim.change_devicetype'):
consoleport_table.base_columns['pk'].visible = True
consoleserverport_table.base_columns['pk'].visible = True
powerport_table.base_columns['pk'].visible = True
poweroutlet_table.base_columns['pk'].visible = True
mgmt_interface_table.base_columns['pk'].visible = True
interface_table.base_columns['pk'].visible = True
devicebay_table.base_columns['pk'].visible = True
return render(request, 'dcim/devicetype.html', {
'devicetype': devicetype,
@@ -273,7 +295,9 @@ def devicetype(request, pk):
'consoleserverport_table': consoleserverport_table,
'powerport_table': powerport_table,
'poweroutlet_table': poweroutlet_table,
'mgmt_interface_table': mgmt_interface_table,
'interface_table': interface_table,
'devicebay_table': devicebay_table,
})
@@ -329,7 +353,7 @@ class ComponentTemplateCreateView(View):
return render(request, 'dcim/component_template_add.html', {
'devicetype': devicetype,
'component_type': self.model._meta.verbose_name,
'form': self.form(),
'form': self.form(initial=request.GET),
'cancel_url': reverse('dcim:devicetype', kwargs={'pk': devicetype.pk}),
})
@@ -353,7 +377,7 @@ class ComponentTemplateCreateView(View):
if not form.errors:
self.model.objects.bulk_create(component_templates)
messages.success(request, "Added {} compontent(s) to {}".format(len(component_templates), devicetype))
messages.success(request, "Added {} component(s) to {}".format(len(component_templates), devicetype))
if '_addanother' in request.POST:
return redirect(request.path)
else:
@@ -392,6 +416,11 @@ class InterfaceTemplateAddView(ComponentTemplateCreateView):
form = forms.InterfaceTemplateForm
class DeviceBayTemplateAddView(ComponentTemplateCreateView):
model = DeviceBayTemplate
form = forms.DeviceBayTemplateForm
def component_template_delete(request, pk, model):
devicetype = get_object_or_404(DeviceType, pk=pk)
@@ -418,7 +447,7 @@ def component_template_delete(request, pk, model):
else:
form = ComponentTemplateBulkDeleteForm(initial={'pk': request.POST.getlist('pk')})
selected_objects = model.objects.filter(pk__in=form.initial.get('pk'))
selected_objects = model.objects.filter(pk__in=request.POST.getlist('pk'))
if not selected_objects:
messages.warning(request, "No {} were selected for deletion.".format(model._meta.verbose_name_plural))
return redirect('dcim:devicetype', pk=devicetype.pk)
@@ -488,7 +517,8 @@ class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
#
class DeviceListView(ObjectListView):
queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'rack__site', 'primary_ip')
queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'rack__site', 'primary_ip4',
'primary_ip6')
filter = filters.DeviceFilter
filter_form = forms.DeviceFilterForm
table = tables.DeviceTable
@@ -499,14 +529,26 @@ class DeviceListView(ObjectListView):
def device(request, pk):
device = get_object_or_404(Device, pk=pk)
console_ports = ConsolePort.objects.filter(device=device).select_related('cs_port__device')
cs_ports = ConsoleServerPort.objects.filter(device=device).select_related('connected_console')
power_ports = PowerPort.objects.filter(device=device).select_related('power_outlet__device')
power_outlets = PowerOutlet.objects.filter(device=device).select_related('connected_port')
console_ports = natsorted(
ConsolePort.objects.filter(device=device).select_related('cs_port__device'), key=attrgetter('name')
)
cs_ports = natsorted(
ConsoleServerPort.objects.filter(device=device).select_related('connected_console'), key=attrgetter('name')
)
power_ports = natsorted(
PowerPort.objects.filter(device=device).select_related('power_outlet__device'), key=attrgetter('name')
)
power_outlets = natsorted(
PowerOutlet.objects.filter(device=device).select_related('connected_port'), key=attrgetter('name')
)
interfaces = Interface.objects.filter(device=device, mgmt_only=False)\
.select_related('connected_as_a', 'connected_as_b', 'circuit')
mgmt_interfaces = Interface.objects.filter(device=device, mgmt_only=True)\
.select_related('connected_as_a', 'connected_as_b', 'circuit')
device_bays = natsorted(
DeviceBay.objects.filter(device=device).select_related('installed_device__device_type__manufacturer'),
key=attrgetter('name')
)
# Gather any secrets which belong to this device
secrets = device.secrets.all()
@@ -537,6 +579,7 @@ def device(request, pk):
'power_outlets': power_outlets,
'interfaces': interfaces,
'mgmt_interfaces': mgmt_interfaces,
'device_bays': device_bays,
'ip_addresses': ip_addresses,
'secrets': secrets,
'related_devices': related_devices,
@@ -547,7 +590,7 @@ class DeviceEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_device'
model = Device
form_class = forms.DeviceForm
fields_initial = ['site', 'rack', 'position', 'face']
fields_initial = ['site', 'rack', 'position', 'face', 'device_bay']
template_name = 'dcim/device_edit.html'
cancel_url = 'dcim:device_list'
@@ -1237,6 +1280,7 @@ def interface_add(request, pk):
'device': device.pk,
'name': name,
'form_factor': form.cleaned_data['form_factor'],
'mac_address': form.cleaned_data['mac_address'],
'mgmt_only': form.cleaned_data['mgmt_only'],
'description': form.cleaned_data['description'],
})
@@ -1324,6 +1368,7 @@ class InterfaceBulkAddView(PermissionRequiredMixin, BulkEditView):
iface_form = forms.InterfaceForm({
'device': device.pk,
'name': name,
'mac_address': form.cleaned_data['mac_address'],
'form_factor': form.cleaned_data['form_factor'],
'mgmt_only': form.cleaned_data['mgmt_only'],
'description': form.cleaned_data['description'],
@@ -1339,6 +1384,143 @@ class InterfaceBulkAddView(PermissionRequiredMixin, BulkEditView):
len(selected_devices)))
#
# Device bays
#
@permission_required('dcim.add_devicebay')
def devicebay_add(request, pk):
device = get_object_or_404(Device, pk=pk)
if request.method == 'POST':
form = forms.DeviceBayCreateForm(request.POST)
if form.is_valid():
device_bays = []
for name in form.cleaned_data['name_pattern']:
devicebay_form = forms.DeviceBayForm({
'device': device.pk,
'name': name,
})
if devicebay_form.is_valid():
device_bays.append(devicebay_form.save(commit=False))
else:
for err in devicebay_form.errors.get('__all__', []):
form.add_error('name_pattern', err)
if not form.errors:
DeviceBay.objects.bulk_create(device_bays)
messages.success(request, "Added {} device bay(s) to {}".format(len(device_bays), device))
if '_addanother' in request.POST:
return redirect('dcim:devicebay_add', pk=device.pk)
else:
return redirect('dcim:device', pk=device.pk)
else:
form = forms.DeviceBayCreateForm()
return render(request, 'dcim/devicebay_edit.html', {
'device': device,
'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}),
})
@permission_required('dcim.change_devicebay')
def devicebay_edit(request, pk):
devicebay = get_object_or_404(DeviceBay, pk=pk)
if request.method == 'POST':
form = forms.DeviceBayForm(request.POST, instance=devicebay)
if form.is_valid():
devicebay = form.save()
messages.success(request, "Modified {} bay {}".format(devicebay.device.name, devicebay.name))
return redirect('dcim:device', pk=devicebay.device.pk)
else:
form = forms.DeviceBayForm(instance=devicebay)
return render(request, 'dcim/devicebay_edit.html', {
'devicebay': devicebay,
'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': devicebay.device.pk}),
})
@permission_required('dcim.delete_devicebay')
def devicebay_delete(request, pk):
devicebay = get_object_or_404(DeviceBay, pk=pk)
if request.method == 'POST':
form = ConfirmationForm(request.POST)
if form.is_valid():
devicebay.delete()
messages.success(request, "Device bay {} has been deleted from {}".format(devicebay, devicebay.device))
return redirect('dcim:device', pk=devicebay.device.pk)
else:
form = ConfirmationForm()
return render(request, 'dcim/devicebay_delete.html', {
'devicebay': devicebay,
'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': devicebay.device.pk}),
})
@permission_required('dcim.change_devicebay')
def devicebay_populate(request, pk):
device_bay = get_object_or_404(DeviceBay, pk=pk)
if request.method == 'POST':
form = forms.PopulateDeviceBayForm(device_bay, request.POST)
if form.is_valid():
device_bay.installed_device = form.cleaned_data['installed_device']
device_bay.save()
if not form.errors:
messages.success(request, "Added {} to {}".format(device_bay.installed_device, device_bay))
return redirect('dcim:device', pk=device_bay.device.pk)
else:
form = forms.PopulateDeviceBayForm(device_bay)
return render(request, 'dcim/devicebay_populate.html', {
'device_bay': device_bay,
'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': device_bay.device.pk}),
})
@permission_required('dcim.change_devicebay')
def devicebay_depopulate(request, pk):
device_bay = get_object_or_404(DeviceBay, pk=pk)
if request.method == 'POST':
form = ConfirmationForm(request.POST)
if form.is_valid():
removed_device = device_bay.installed_device
device_bay.installed_device = None
device_bay.save()
messages.success(request, "{} has been removed from {}".format(removed_device, device_bay))
return redirect('dcim:device', pk=device_bay.device.pk)
else:
form = ConfirmationForm()
return render(request, 'dcim/devicebay_depopulate.html', {
'device_bay': device_bay,
'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': device_bay.device.pk}),
})
#
# Interface connections
#
@@ -1480,7 +1662,10 @@ def ipaddress_assign(request, pk):
ipaddress.interface))
if form.cleaned_data['set_as_primary']:
device.primary_ip = ipaddress
if ipaddress.family == 4:
device.primary_ip4 = ipaddress
elif ipaddress.family == 6:
device.primary_ip6 = ipaddress
device.save()
if '_addanother' in request.POST:
@@ -1514,6 +1699,9 @@ def module_add(request, pk):
module.device = device
module.save()
messages.success(request, "Added module {} to {}".format(module.name, module.device.name))
if '_addanother' in request.POST:
return redirect('dcim:module_add', pk=module.device.pk)
else:
return redirect('dcim:device_inventory', pk=module.device.pk)
else:

View File

@@ -1,4 +1,4 @@
import pydot
import graphviz
from rest_framework import generics
from rest_framework.views import APIView
import tempfile
@@ -49,32 +49,30 @@ class TopologyMapView(APIView):
tmap = get_object_or_404(TopologyMap, slug=slug)
# Construct the graph
graph = pydot.Dot(graph_type='graph', ranksep='1')
graph = graphviz.Graph()
graph.graph_attr['ranksep'] = '1'
for i, device_set in enumerate(tmap.device_sets):
subgraph = pydot.Subgraph('sg{}'.format(i), rank='same')
subgraph = graphviz.Graph(name='sg{}'.format(i))
subgraph.graph_attr['rank'] = 'same'
# Add a pseudonode for each device_set to enforce hierarchical layout
subgraph.add_node(pydot.Node('set{}'.format(i), shape='none', width='0', label=''))
subgraph.node('set{}'.format(i), label='', shape='none', width='0')
if i:
graph.add_edge(pydot.Edge('set{}'.format(i - 1), 'set{}'.format(i), style='invis'))
graph.edge('set{}'.format(i - 1), 'set{}'.format(i), style='invis')
# Add each device to the graph
devices = []
for query in device_set.split(','):
devices += Device.objects.filter(name__regex=query)
for d in devices:
node = pydot.Node(d.name)
subgraph.add_node(node)
subgraph.node(d.name)
# Add an invisible connection to each successive device in a set to enforce horizontal order
for j in range(0, len(devices) - 1):
edge = pydot.Edge(devices[j].name, devices[j + 1].name)
# edge.set('style', 'invis') doesn't seem to work for some reason
edge.set_style('invis')
subgraph.add_edge(edge)
subgraph.edge(devices[j].name, devices[j + 1].name, style='invis')
graph.add_subgraph(subgraph)
graph.subgraph(subgraph)
# Compile list of all devices
device_superset = Q()
@@ -87,17 +85,14 @@ class TopologyMapView(APIView):
connections = InterfaceConnection.objects.filter(interface_a__device__in=devices,
interface_b__device__in=devices)
for c in connections:
edge = pydot.Edge(c.interface_a.device.name, c.interface_b.device.name)
graph.add_edge(edge)
graph.edge(c.interface_a.device.name, c.interface_b.device.name)
# Write the image to disk and return
topo_file = tempfile.NamedTemporaryFile()
# Get the image data and return
try:
graph.write(topo_file.name, format='png')
topo_data = graph.pipe(format='png')
except:
return HttpResponse("There was an error generating the requested graph. Ensure that the GraphViz "
"executables have been installed correctly.")
response = HttpResponse(FileWrapper(topo_file), content_type='image/png')
topo_file.close()
response = HttpResponse(topo_data, content_type='image/png')
return response

View File

@@ -1,7 +1,7 @@
from rest_framework import generics
from ipam.models import VRF, Role, RIR, Aggregate, Prefix, IPAddress, VLAN
from ipam.filters import AggregateFilter, PrefixFilter, IPAddressFilter, VLANFilter
from ipam.filters import AggregateFilter, PrefixFilter, IPAddressFilter, VLANFilter, VRFFilter
from . import serializers
@@ -12,6 +12,7 @@ class VRFListView(generics.ListAPIView):
"""
queryset = VRF.objects.all()
serializer_class = serializers.VRFSerializer
filter_class = VRFFilter
class VRFDetailView(generics.RetrieveAPIView):

View File

@@ -10,6 +10,11 @@ from .lookups import (
)
def prefix_validator(prefix):
if prefix.ip != prefix.cidr.ip:
raise ValidationError("{} is not a valid prefix. Did you mean {}?".format(prefix, prefix.cidr))
class BaseIPField(models.Field):
def python_type(self):
@@ -45,6 +50,7 @@ class IPNetworkField(BaseIPField):
IP prefix (network and mask)
"""
description = "PostgreSQL CIDR field"
default_validators = [prefix_validator]
def db_type(self, connection):
return 'cidr'

View File

@@ -46,9 +46,14 @@ class PrefixFilter(django_filters.FilterSet):
action='search_by_parent',
label='Parent prefix',
)
vrf = django_filters.MethodFilter(
action='_vrf',
label='VRF',
)
# Duplicate of `vrf` for backward-compatibility
vrf_id = django_filters.MethodFilter(
action='vrf',
label='VRF (ID)',
action='_vrf',
label='VRF',
)
site_id = django_filters.ModelMultipleChoiceFilter(
name='site',
@@ -84,7 +89,7 @@ class PrefixFilter(django_filters.FilterSet):
class Meta:
model = Prefix
fields = ['family', 'site_id', 'site', 'vrf_id', 'vrf', 'vlan_id', 'vlan_vid', 'status', 'role_id', 'role']
fields = ['family', 'site_id', 'site', 'vrf', 'vrf_id', 'vlan_id', 'vlan_vid', 'status', 'role_id', 'role']
def search(self, queryset, value):
value = value.strip()
@@ -104,7 +109,7 @@ class PrefixFilter(django_filters.FilterSet):
except AddrFormatError:
return queryset.none()
def vrf(self, queryset, value):
def _vrf(self, queryset, value):
if str(value) == '':
return queryset
try:
@@ -121,10 +126,14 @@ class IPAddressFilter(django_filters.FilterSet):
action='search',
label='Search',
)
vrf_id = django_filters.ModelMultipleChoiceFilter(
name='vrf',
queryset=VRF.objects.all(),
label='VRF (ID)',
vrf = django_filters.MethodFilter(
action='_vrf',
label='VRF',
)
# Duplicate of `vrf` for backward-compatibility
vrf_id = django_filters.MethodFilter(
action='_vrf',
label='VRF',
)
device_id = django_filters.ModelMultipleChoiceFilter(
name='interface__device',
@@ -155,6 +164,17 @@ class IPAddressFilter(django_filters.FilterSet):
except AddrFormatError:
return queryset.none()
def _vrf(self, queryset, value):
if str(value) == '':
return queryset
try:
vrf_id = int(value)
except ValueError:
return queryset.none()
if vrf_id == 0:
return queryset.filter(vrf__isnull=True)
return queryset.filter(vrf__pk=value)
class VLANFilter(django_filters.FilterSet):
site_id = django_filters.ModelMultipleChoiceFilter(

View File

@@ -13,6 +13,10 @@ from .models import (
)
FORM_PREFIX_STATUS_CHOICES = (('', '---------'),) + PREFIX_STATUS_CHOICES
FORM_VLAN_STATUS_CHOICES = (('', '---------'),) + VLAN_STATUS_CHOICES
#
# VRFs
#
@@ -215,6 +219,7 @@ class PrefixBulkEditForm(forms.Form, BootstrapMixin):
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF',
help_text="Select the VRF to assign, or check below to remove VRF assignment")
vrf_global = forms.BooleanField(required=False, label='Set VRF to global')
status = forms.ChoiceField(choices=FORM_PREFIX_STATUS_CHOICES, required=False)
role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False)
description = forms.CharField(max_length=50, required=False)
@@ -324,7 +329,7 @@ class IPAddressForm(forms.ModelForm, BootstrapMixin):
class IPAddressFromCSVForm(forms.ModelForm):
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, to_field_name='rd',
error_messages={'invalid_choice': 'Site not found.'})
error_messages={'invalid_choice': 'VRF not found.'})
device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, to_field_name='name',
error_messages={'invalid_choice': 'Device not found.'})
interface_name = forms.CharField(required=False)
@@ -363,7 +368,10 @@ class IPAddressFromCSVForm(forms.ModelForm):
name=self.cleaned_data['interface_name'])
# Set as primary for device
if self.cleaned_data['is_primary']:
self.instance.primary_for = self.cleaned_data['device']
if self.instance.family == 4:
self.instance.primary_ip4_for = self.cleaned_data['device']
elif self.instance.family == 6:
self.instance.primary_ip6_for = self.cleaned_data['device']
return super(IPAddressFromCSVForm, self).save(commit=commit)
@@ -444,6 +452,7 @@ class VLANImportForm(BulkImportForm, BootstrapMixin):
class VLANBulkEditForm(forms.Form, BootstrapMixin):
pk = forms.ModelMultipleChoiceField(queryset=VLAN.objects.all(), widget=forms.MultipleHiddenInput)
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False)
status = forms.ChoiceField(choices=FORM_VLAN_STATUS_CHOICES, required=False)
role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False)

View File

@@ -121,6 +121,14 @@ class Aggregate(CreatedUpdatedModel):
raise ValidationError("{} is already covered by an existing aggregate ({})"
.format(self.prefix, covering_aggregates[0]))
# Ensure that the aggregate being added does not cover an existing aggregate
covered_aggregates = Aggregate.objects.filter(prefix__net_contained=str(self.prefix))
if self.pk:
covered_aggregates = covered_aggregates.exclude(pk=self.pk)
if covered_aggregates:
raise ValidationError("{} is overlaps with an existing aggregate ({})"
.format(self.prefix, covered_aggregates[0]))
def save(self, *args, **kwargs):
if self.prefix:
# Infer address family from IPNetwork object
@@ -308,12 +316,20 @@ class IPAddress(CreatedUpdatedModel):
super(IPAddress, self).save(*args, **kwargs)
def to_csv(self):
# Determine if this IP is primary for a Device
is_primary = False
if self.family == 4 and getattr(self, 'primary_ip4_for', False):
is_primary = True
elif self.family == 6 and getattr(self, 'primary_ip6_for', False):
is_primary = True
return ','.join([
str(self.address),
self.vrf.rd if self.vrf else '',
self.device.identifier if self.device else '',
self.interface.name if self.interface else '',
'True' if getattr(self, 'primary_for', False) else '',
'True' if is_primary else '',
self.description,
])
@@ -361,7 +377,7 @@ class VLAN(CreatedUpdatedModel):
@property
def display_name(self):
return "{} ({})".format(self.vid, self.name)
return u"{} ({})".format(self.vid, self.name)
def get_status_class(self):
return STATUS_CHOICE_CLASSES[self.status]

View File

@@ -364,7 +364,7 @@ def prefix_ipaddresses(request, pk):
# Find all IPAddresses belonging to this Prefix
ipaddresses = IPAddress.objects.filter(address__net_contained_or_equal=str(prefix.prefix))\
.select_related('vrf', 'interface__device', 'primary_for')
.select_related('vrf', 'interface__device', 'primary_ip4_for', 'primary_ip6_for')
ip_table = tables.IPAddressTable(ipaddresses)
ip_table.model = IPAddress
@@ -383,7 +383,7 @@ def prefix_ipaddresses(request, pk):
#
class IPAddressListView(ObjectListView):
queryset = IPAddress.objects.select_related('vrf', 'interface__device', 'primary_for')
queryset = IPAddress.objects.select_related('vrf', 'interface__device', 'primary_ip4_for', 'primary_ip6_for')
filter = filters.IPAddressFilter
filter_form = forms.IPAddressFilterForm
table = tables.IPAddressTable
@@ -395,16 +395,24 @@ def ipaddress(request, pk):
ipaddress = get_object_or_404(IPAddress.objects.select_related('interface__device'), pk=pk)
# Parent prefixes table
parent_prefixes = Prefix.objects.filter(vrf=ipaddress.vrf, prefix__net_contains=str(ipaddress.address.ip))
related_ips = IPAddress.objects.select_related('interface__device').exclude(pk=ipaddress.pk)\
.filter(vrf=ipaddress.vrf, address__net_contained_or_equal=str(ipaddress.address))
parent_prefixes_table = tables.PrefixBriefTable(parent_prefixes)
# Duplicate IPs table
duplicate_ips = IPAddress.objects.filter(vrf=ipaddress.vrf, address=str(ipaddress.address))\
.exclude(pk=ipaddress.pk).select_related('interface__device', 'nat_inside')
duplicate_ips_table = tables.IPAddressBriefTable(duplicate_ips)
# Related IP table
related_ips = IPAddress.objects.select_related('interface__device').exclude(address=str(ipaddress.address))\
.filter(vrf=ipaddress.vrf, address__net_contained_or_equal=str(ipaddress.address))
related_ips_table = tables.IPAddressBriefTable(related_ips)
RequestConfig(request, paginate={'klass': EnhancedPaginator}).configure(related_ips_table)
return render(request, 'ipam/ipaddress.html', {
'ipaddress': ipaddress,
'parent_prefixes': parent_prefixes,
'parent_prefixes_table': parent_prefixes_table,
'duplicate_ips_table': duplicate_ips_table,
'related_ips_table': related_ips_table,
})
@@ -435,8 +443,13 @@ class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView):
obj.save()
# Update primary IP for device if needed
try:
device = obj.primary_for
device.primary_ip = obj
if obj.family == 4 and obj.primary_ip4_for:
device = obj.primary_ip4_for
device.primary_ip4 = obj
device.save()
elif obj.family == 6 and obj.primary_ip6_for:
device = obj.primary_ip6_for
device.primary_ip6 = obj
device.save()
except Device.DoesNotExist:
pass

View File

@@ -0,0 +1,75 @@
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', '')]
# 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)
# 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.
NETBOX_USERNAME = os.environ.get('NETBOX_USERNAME', '')
NETBOX_PASSWORD = os.environ.get('NETBOX_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

@@ -73,3 +73,12 @@ TIME_FORMAT = 'g:i a'
SHORT_TIME_FORMAT = 'H:i:s'
DATETIME_FORMAT = 'N j, Y g:i a'
SHORT_DATETIME_FORMAT = 'Y-m-d H:i'
# 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.
BANNER_TOP = ''
BANNER_BOTTOM = ''
# When determining the primary IP address for a device, IPv6 is preferred over IPv4 by default. Set this to True to
# prefer IPv4 instead.
PREFER_IPV4 = False

View File

@@ -1,3 +1,4 @@
import logging
import os
import socket
@@ -11,6 +12,8 @@ except ImportError:
"the documentation.")
VERSION = '1.2.3-dev'
# Import local configuration
for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:
try:
@@ -35,8 +38,40 @@ TIME_FORMAT = getattr(configuration, 'TIME_FORMAT', 'g:i a')
SHORT_TIME_FORMAT = getattr(configuration, 'SHORT_TIME_FORMAT', 'H:i:s')
DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a')
SHORT_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H:i')
BANNER_TOP = getattr(configuration, 'BANNER_TOP', False)
BANNER_BOTTOM = getattr(configuration, 'BANNER_BOTTOM', False)
PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False)
CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS
# Attempt to import LDAP configuration if it has been defined
LDAP_IGNORE_CERT_ERRORS = False
try:
from ldap_config import *
LDAP_CONFIGURED = True
except ImportError:
LDAP_CONFIGURED = False
# LDAP configuration (optional)
if LDAP_CONFIGURED:
try:
import ldap
import django_auth_ldap
# Prepend LDAPBackend to the default ModelBackend
AUTHENTICATION_BACKENDS = [
'django_auth_ldap.backend.LDAPBackend',
'django.contrib.auth.backends.ModelBackend',
]
# Optionally disable strict certificate checking
if LDAP_IGNORE_CERT_ERRORS:
ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
# Enable logging for django_auth_ldap
logger = logging.getLogger('django_auth_ldap')
logger.addHandler(logging.StreamHandler())
logger.setLevel(logging.DEBUG)
except ImportError:
raise ImproperlyConfigured("LDAP authentication has been configured, but django-auth-ldap is not installed. "
"You can remove netbox/ldap.py to disable LDAP.")
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Database
@@ -103,7 +138,6 @@ TEMPLATES = [
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
'utilities.context_processors.settings',
'django.core.context_processors.request',
],
},
},

View File

@@ -2,7 +2,7 @@ from django.conf.urls import include, url
from django.contrib import admin
from django.views.defaults import page_not_found
from views import home, docs, trigger_500
from views import home, trigger_500
from users.views import login, logout
@@ -30,10 +30,6 @@ urlpatterns = [
url(r'^api/docs/', include('rest_framework_swagger.urls')),
url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework')),
# Dcoumentation
url(r'^docs/$', docs, kwargs={'path': 'index'}, name='docs_root'),
url(r'^docs/(?P<path>[\w-]+)/$', docs, name='docs'),
# Error testing
url(r'^404/$', page_not_found),
url(r'^500/$', trigger_500),

View File

@@ -41,26 +41,7 @@ def home(request):
return render(request, 'home.html', {
'stats': stats,
'recent_activity': UserAction.objects.all()[:15]
})
def docs(request, path):
"""
Display a page of Markdown-formatted documentation.
"""
filename = '{}/docs/{}.md'.format(settings.BASE_DIR.rsplit('/', 1)[0], path)
try:
with open(filename, 'r') as docfile:
markup = docfile.read()
except:
raise Http404
content = mark_safe(markdown(markup, extensions=['mdx_gfm', 'toc']))
return render(request, 'docs.html', {
'content': content,
'path': path,
'recent_activity': UserAction.objects.select_related('user')[:15]
})

View File

@@ -2,6 +2,9 @@
* {
margin: 0;
}
html {
overflow-y: scroll;
}
html, body {
height: 100%;
}
@@ -9,7 +12,8 @@ body {
padding-top: 70px;
}
.container {
width: 1340px;
width: auto;
max-width: 1340px;
}
.wrapper {
min-height: 100%;
@@ -27,6 +31,42 @@ body {
footer p {
margin: 20px 0;
}
@media (max-width: 1120px) {
.navbar-header {
float: none;
}
.navbar-left,.navbar-right {
float: none !important;
}
.navbar-toggle {
display: block;
}
.navbar-collapse {
border-top: 1px solid transparent;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.1);
}
.navbar-fixed-top {
top: 0;
border-width: 0 0 1px;
}
.navbar-collapse.collapse {
display: none!important;
}
.navbar-nav {
float: none!important;
margin-top: 7.5px;
}
.navbar-nav>li {
float: none;
}
.navbar-nav>li>a {
padding-top: 10px;
padding-bottom: 10px;
}
.collapse.in {
display:block !important;
}
}
/* Forms */
label {
@@ -258,6 +298,9 @@ ul.rack_near_face li.empty:hover a {
.dark_gray:hover { background-color: #2c3e50; }
/* Misc */
.banner-bottom {
margin-bottom: 50px;
}
.panel table {
margin-bottom: 0;
}

View File

@@ -1,15 +1,21 @@
$(document).ready(function() {
// "Select all" checkbox in a table header
$('th input:checkbox').click(function (event) {
$('th input:checkbox[name=_all]').click(function (event) {
$(this).parents('table').find('td input:checkbox').prop('checked', $(this).prop('checked'));
});
// Uncheck the "select all" checkbox if an item is unchecked
$('input:checkbox[name=pk]').click(function (event) {
if (!$(this).attr('checked')) {
$(this).parents('table').find('input:checkbox[name=_all]').prop('checked', false);
}
});
// Slugify
function slugify(s, num_chars) {
s = s.replace(/[^-\.\+\w\s]/g, ''); // Remove unneeded chars
s = s.replace(/[^\-\.\w\s]/g, ''); // Remove unneeded chars
s = s.replace(/^\s+|\s+$/g, ''); // Trim leading/trailing spaces
s = s.replace(/[-\s]+/g, '-'); // Convert spaces to hyphens
s = s.replace(/[\-\.\s]+/g, '-'); // Convert spaces and decimals to hyphens
s = s.toLowerCase(); // Convert to lowercase
return s.substring(0, num_chars); // Trim to first num_chars chars
}

View File

@@ -4,6 +4,7 @@ from django.shortcuts import get_object_or_404
from rest_framework import generics
from rest_framework import status
from rest_framework.exceptions import PermissionDenied
from rest_framework.permissions import IsAuthenticated
from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response
@@ -41,7 +42,7 @@ class SecretListView(generics.GenericAPIView):
"""
List secrets (filterable). If a private key is POSTed, attempt to decrypt each Secret.
"""
queryset = Secret.objects.select_related('device__primary_ip', 'role')\
queryset = Secret.objects.select_related('device__primary_ip4', 'device__primary_ip6', 'role')\
.prefetch_related('role__users', 'role__groups')
serializer_class = serializers.SecretSerializer
filter_class = SecretFilter
@@ -51,7 +52,7 @@ class SecretListView(generics.GenericAPIView):
queryset = self.filter_queryset(self.get_queryset())
# Attempt to decrypt each Secret if a private key was provided.
if private_key is not None:
if private_key:
try:
uk = UserKey.objects.get(user=request.user)
except UserKey.DoesNotExist:
@@ -86,7 +87,7 @@ class SecretDetailView(generics.GenericAPIView):
"""
Retrieve a single Secret. If a private key is POSTed, attempt to decrypt the Secret.
"""
queryset = Secret.objects.select_related('device__primary_ip', 'role')\
queryset = Secret.objects.select_related('device__primary_ip4', 'device__primary_ip6', 'role')\
.prefetch_related('role__users', 'role__groups')
serializer_class = serializers.SecretSerializer
renderer_classes = [FormlessBrowsableAPIRenderer, JSONRenderer, FreeRADIUSClientsRenderer]
@@ -95,7 +96,7 @@ class SecretDetailView(generics.GenericAPIView):
secret = get_object_or_404(Secret, pk=pk)
# Attempt to decrypt the Secret if a private key was provided.
if private_key is not None:
if private_key:
try:
uk = UserKey.objects.get(user=request.user)
except UserKey.DoesNotExist:
@@ -108,7 +109,8 @@ class SecretDetailView(generics.GenericAPIView):
{'error': ERR_USERKEY_INACTIVE},
status=status.HTTP_400_BAD_REQUEST
)
if secret.decryptable_by(request.user):
if not secret.decryptable_by(request.user):
raise PermissionDenied(detail="You do not have permission to decrypt this secret.")
master_key = uk.get_master_key(private_key)
if master_key is None:
return Response(

View File

@@ -182,6 +182,14 @@ class SecretRole(models.Model):
def get_absolute_url(self):
return "{}?role={}".format(reverse('secrets:secret_list'), self.slug)
def has_member(self, user):
"""
Check whether the given user has belongs to this SecretRole. Note that superusers belong to all roles.
"""
if user.is_superuser:
return True
return user in self.users.all() or user.groups.filter(pk__in=self.groups.all()).exists()
class Secret(CreatedUpdatedModel):
"""
@@ -304,4 +312,4 @@ class Secret(CreatedUpdatedModel):
"""
Check whether the given user has permission to decrypt this Secret.
"""
return user in self.role.users.all() or user.groups.filter(pk__in=self.role.groups.all()).exists()
return self.role.has_member(user)

View File

View File

@@ -0,0 +1,12 @@
from django import template
register = template.Library()
@register.filter()
def decryptable_by(secret, user):
"""
Determine whether a given User is permitted to decrypt a Secret.
"""
return secret.decryptable_by(user)

View File

@@ -17,6 +17,8 @@
<div class="panel-body">
<p>There was a problem with your request. This error has been logged and administrative staff have
been notified. Please return to the home page and try again.</p>
<p>If you are responsible for this installation, please consider
<a href="https://github.com/digitalocean/netbox/issues">filing a bug report</a>.</p>
<div class="text-right">
<a href="/" class="btn btn-primary">Home Page</a>
</div>

View File

@@ -13,9 +13,16 @@
<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="/">NetBox</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|startswith:'/dcim/sites/' %} active{% endif %}">
{% if perms.dcim.add_site %}
@@ -201,11 +208,12 @@
</li>
{% endif %}
</ul>
{% endif %}
<ul class="nav navbar-nav navbar-right">
{% if request.user.is_authenticated %}
{% if request.user.is_staff %}
<li><a href="{% url 'admin:index' %}"><i class="glyphicon glyphicon-cog" aria-hidden="true"></i> Admin</a></li>
{% endif %}
{% if request.user.is_authenticated %}
<li><a href="{% url 'users:profile' %}"><i class="glyphicon glyphicon-user" aria-hidden="true"></i> Profile</a></li>
<li><a href="{% url 'logout' %}"><i class="glyphicon glyphicon-log-out" aria-hidden="true"></i> Log out</a></li>
{% else %}
@@ -216,10 +224,15 @@
</div>
</nav>
<div class="container wrapper">
{% if settings.BANNER_TOP %}
<div class="alert alert-info text-center" role="alert">
{{ settings.BANNER_TOP|safe }}
</div>
{% endif %}
{% if settings.MAINTENANCE_MODE %}
<div class="alert alert-warning text-center" role="alert">
<h4><i class="fa fa-exclamation-triangle"></i> Maintenance Mode</h4>
<p>The application is currently in maintenance mode.</p>
<p>NetBox is currently in maintenance mode. Functionality may be limited.</p>
</div>
{% endif %}
{% for message in messages %}
@@ -232,21 +245,26 @@
{% endfor %}
{% block content %}{% endblock %}
<div class="push"></div>
{% if settings.BANNER_BOTTOM %}
<div class="alert alert-info text-center banner-bottom" role="alert">
{{ settings.BANNER_BOTTOM|safe }}
</div>
{% endif %}
</div>
<footer class="footer">
<div class="container">
<div class="row">
<div class="col-md-4">
<p class="text-muted">{{ settings.HOSTNAME }}</p>
<div class="col-xs-4">
<p class="text-muted">{{ settings.HOSTNAME }} (v{{ settings.VERSION }})</p>
</div>
<div class="col-md-4 text-center">
<div class="col-xs-4 text-center">
<p class="text-muted">{% now 'Y-m-d H:i:s T' %}</p>
</div>
<div class="col-md-4 text-right">
<div class="col-xs-4 text-right">
<p class="text-muted">
<i class="fa fa-fw fa-book text-primary"></i> <a href="{% url 'docs_root' %}">Docs</a> &middot;
<i class="fa fa-fw fa-book text-primary"></i> <a href="http://netbox.readthedocs.io/" target="_blank">Docs</a> &middot;
<i class="fa fa-fw fa-cloud text-primary"></i> <a href="/api/docs/">API</a> &middot;
<i class="fa fa-fw fa-code text-primary"></i><a href="https://github.com/digitalocean/netbox">Code</a>
<i class="fa fa-fw fa-code text-primary"></i> <a href="https://github.com/digitalocean/netbox">Code</a>
</p>
</div>
</div>

View File

@@ -14,8 +14,28 @@
</div>
<h1>Providers</h1>
<div class="row">
<div class="col-md-12">
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_edit_url='circuits:provider_bulk_edit' bulk_delete_url='circuits:provider_bulk_delete' %}
</div>
<div class="col-md-3">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Search</strong>
</div>
<div class="panel-body">
<form action="{% url 'circuits:provider_list' %}" method="get">
<div class="input-group">
<input type="text" name="q" class="form-control" placeholder="Name" {% if request.GET.q %}value="{{ request.GET.q }}" {% endif %}/>
<span class="input-group-btn">
<button type="submit" class="btn btn-primary">
<span class="glyphicon glyphicon-search" aria-hidden="true"></span>
</button>
</span>
</div>
</form>
</div>
</div>
{% include 'inc/filter_panel.html' %}
</div>
</div>
{% endblock %}

View File

@@ -31,7 +31,12 @@
<li class="occupied h{{ u.device.device_type.u_height }}u{% ifequal u.device.face face_id %} {{ u.device.device_role.color }}{% endifequal %}">
{% ifequal u.device.face face_id %}
<a href="{% url 'dcim:device' pk=u.device.pk %}" data-toggle="popover" data-trigger="hover" data-container="body" data-html="true"
data-content="{{ u.device.device_role }}<br />{{ u.device.device_type }} ({{ u.device.device_type.u_height }}U)">{{ u.device.name|default:u.device.device_role }}</a>
data-content="{{ u.device.device_role }}<br />{{ u.device.device_type }} ({{ u.device.device_type.u_height }}U)">
{{ u.device.name|default:u.device.device_role }}
{% if u.device.devicebay_count %}
({{ u.device.get_children.count }}/{{ u.device.devicebay_count }})
{% endif %}
</a>
{% else %}
<span>{{ u.device.name|default:u.device.device_role }}</span>
{% endifequal %}

View File

@@ -1,7 +1,7 @@
{% extends 'utilities/confirmation_form.html' %}
{% load form_helpers %}
{% block title %}Delete devie type components?{% endblock %}
{% block title %}Delete device type components?{% endblock %}
{% block message %}
<p>Are you sure you want to delete these components from <strong>{{ devicetype }}</strong>?</p>

View File

@@ -29,7 +29,12 @@
<tr>
<td>Position</td>
<td>
{% if device.position %}
{% if device.parent_bay %}
{% with device.parent_bay.device as parent %}
<span>U{{ parent.position }} / {{ parent.get_face_display }}
(<a href="{{ parent.get_absolute_url }}">{{ parent }}</a> - {{ device.parent_bay.name }})</span>
{% endwith %}
{% elif device.position %}
<span>U{{ device.position }} / {{ device.get_face_display }}</span>
{% elif device.device_type.u_height %}
<span class="label label-warning">Not racked</span>
@@ -96,14 +101,29 @@
</td>
</tr>
<tr>
<td>Primary IP</td>
<td>Primary IPv4</td>
<td>
{% if device.primary_ip %}
<a href="{% url 'ipam:ipaddress' pk=device.primary_ip.pk %}">{{ device.primary_ip.address.ip }}</a>
{% if device.primary_ip.nat_inside %}
<span>(NAT for {{ device.primary_ip.nat_inside.address.ip }})</span>
{% elif device.primary_ip.nat_outside %}
<span>(NAT: {{ device.primary_ip.nat_outside.address.ip }})</span>
{% if device.primary_ip4 %}
<a href="{% url 'ipam:ipaddress' pk=device.primary_ip4.pk %}">{{ device.primary_ip4.address.ip }}</a>
{% if device.primary_ip4.nat_inside %}
<span>(NAT for {{ device.primary_ip4.nat_inside.address.ip }})</span>
{% elif device.primary_ip4.nat_outside %}
<span>(NAT: {{ device.primary_ip4.nat_outside.address.ip }})</span>
{% endif %}
{% else %}
<span class="text-muted">Not defined</span>
{% endif %}
</td>
</tr>
<tr>
<td>Primary IPv6</td>
<td>
{% if device.primary_ip6 %}
<a href="{% url 'ipam:ipaddress' pk=device.primary_ip6.pk %}">{{ device.primary_ip6.address.ip }}</a>
{% if device.primary_ip6.nat_inside %}
<span>(NAT for {{ device.primary_ip6.nat_inside.address.ip }})</span>
{% elif device.primary_ip6.nat_outside %}
<span>(NAT: {{ device.primary_ip6.nat_outside.address.ip }})</span>
{% endif %}
{% else %}
<span class="text-muted">Not defined</span>
@@ -160,7 +180,7 @@
<div class="panel-footer text-right">
<a href="{% url 'dcim:ipaddress_assign' pk=device.pk %}" class="btn btn-xs btn-primary">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
Assign IP Address
Assign IP address
</a>
</div>
{% endif %}
@@ -174,7 +194,7 @@
{% include 'dcim/inc/_interface.html' with icon='wrench' %}
{% empty %}
<tr>
<td colspan="4" class="alert-warning">
<td colspan="5" class="alert-warning">
<i class="fa fa-fw fa-warning"></i> No management interfaces defined!
{% if perms.dcim.add_interface %}
<a href="{% url 'dcim:interface_add' pk=device.pk %}?mgmt_only=1" class="btn btn-primary btn-xs pull-right"><span class="glyphicon glyphicon-plus" aria-hidden="true"></span></a>
@@ -186,7 +206,7 @@
{% include 'dcim/inc/_consoleport.html' %}
{% empty %}
<tr>
<td colspan="4" class="alert-warning">
<td colspan="5" class="alert-warning">
<i class="fa fa-fw fa-warning"></i> No console ports defined!
{% if perms.dcim.add_consoleport %}
<a href="{% url 'dcim:consoleport_add' pk=device.pk %}" class="btn btn-primary btn-xs pull-right"><span class="glyphicon glyphicon-plus" aria-hidden="true"></span></a>
@@ -199,7 +219,7 @@
{% empty %}
{% if not device.device_type.is_pdu %}
<tr>
<td colspan="4" class="alert-warning">
<td colspan="5" class="alert-warning">
<i class="fa fa-fw fa-warning"></i> No power ports defined!
{% if perms.dcim.add_powerport %}
<a href="{% url 'dcim:powerport_add' pk=device.pk %}" class="btn btn-primary btn-xs pull-right"><span class="glyphicon glyphicon-plus" aria-hidden="true"></span></a>
@@ -268,12 +288,33 @@
</div>
</div>
<div class="col-md-6">
{% if device_bays or device.device_type.is_parent_device %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>Device Bays</strong>
</div>
<table class="table table-hover panel-body">
{% for devicebay in device_bays %}
{% include 'dcim/inc/_devicebay.html' %}
{% empty %}
<tr>
<td colspan="4">No device bays defined</td>
</tr>
{% endfor %}
</table>
{% if perms.dcim.add_devicebay %}
<div class="panel-footer text-right">
<a href="{% url 'dcim:devicebay_add' pk=device.pk %}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
Add device bays
</a>
</div>
{% endif %}
</div>
{% endif %}
{% if interfaces or device.device_type.is_network_device %}
<div class="panel panel-default">
<div class="panel-heading">
{% if perms.dcim.add_interface %}
<a href="{% url 'dcim:interface_add' pk=device.pk %}" class="btn btn-primary btn-xs pull-right"><span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add Interfaces</a>
{% endif %}
<strong>Interfaces</strong>
</div>
<table class="table table-hover panel-body">
@@ -285,14 +326,19 @@
</tr>
{% endfor %}
</table>
{% if perms.dcim.add_interface %}
<div class="panel-footer text-right">
<a href="{% url 'dcim:interface_add' pk=device.pk %}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
Add interface
</a>
</div>
{% endif %}
</div>
{% endif %}
{% if cs_ports or device.device_type.is_console_server %}
<div class="panel panel-default">
<div class="panel-heading">
{% if perms.dcim.add_consoleserverport %}
<a href="{% url 'dcim:consoleserverport_add' pk=device.pk %}" class="btn btn-primary btn-xs pull-right"><span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add Console Server Ports</a>
{% endif %}
<strong>Console Server Ports</strong>
</div>
<table class="table table-hover panel-body">
@@ -304,14 +350,19 @@
</tr>
{% endfor %}
</table>
{% if perms.dcim.add_consoleserverport %}
<div class="panel-footer text-right">
<a href="{% url 'dcim:consoleserverport_add' pk=device.pk %}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
Add console server ports
</a>
</div>
{% endif %}
</div>
{% endif %}
{% if power_outlets or device.device_type.is_pdu %}
<div class="panel panel-default">
<div class="panel-heading">
{% if perms.dcim.add_poweroutlet %}
<a href="{% url 'dcim:poweroutlet_add' pk=device.pk %}" class="btn btn-primary btn-xs pull-right"><span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add Power Outlets</a>
{% endif %}
<strong>Power Outlets</strong>
</div>
<table class="table table-hover panel-body">
@@ -323,6 +374,14 @@
</tr>
{% endfor %}
</table>
{% if perms.dcim.add_poweroutlet %}
<div class="panel-footer text-right">
<a href="{% url 'dcim:poweroutlet_add' pk=device.pk %}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
Add power outlets
</a>
</div>
{% endif %}
</div>
{% endif %}
</div>

View File

@@ -31,7 +31,10 @@
<div class="panel-body">
{% render_field form.platform %}
{% render_field form.status %}
{% if obj %}{% render_field form.primary_ip %}{% endif %}
{% if obj %}
{% render_field form.primary_ip4 %}
{% render_field form.primary_ip6 %}
{% endif %}
</div>
</div>
<div class="panel panel-default">

View File

@@ -68,18 +68,18 @@
</tr>
<tr>
<td>Position (U)</td>
<td>Numeric rack position (optional)</td>
<td>Lowest rack unit occupied by the device (optional)</td>
<td>21</td>
</tr>
<tr>
<td>Face</td>
<td>Rack face; front or rear (optional)</td>
<td>rear</td>
<td>Rear</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>rack101_sw1,ToR Switch,Juniper,EX4300-48T,Juniper Junos,CAB00577291,Ashburn-VA,R101,21,rear</pre>
<pre>rack101_sw1,ToR Switch,Juniper,EX4300-48T,Juniper Junos,CAB00577291,Ashburn-VA,R101,21,Rear</pre>
</div>
</div>
{% endblock %}

View File

@@ -25,6 +25,7 @@
<div class="col-md-3">
<div class="panel panel-default">
<div class="panel-heading">
<span class="glyphicon glyphicon-search" aria-hidden="true"></span>
<strong>Search</strong>
</div>
<div class="panel-body">

View File

@@ -0,0 +1,8 @@
{% extends 'utilities/confirmation_form.html' %}
{% load form_helpers %}
{% block title %}Delete device bay {{ devicebay }}?{% endblock %}
{% block message %}
<p>Are you sure you want to delete this device bay from <strong>{{ devicebay.device }}</strong>?</p>
{% endblock %}

View File

@@ -0,0 +1,8 @@
{% extends 'utilities/confirmation_form.html' %}
{% load form_helpers %}
{% block title %}Remove {{ device_bay.installed_device }} from {{ device_bay }}?{% endblock %}
{% block message %}
<p>Are you sure you want to remove <strong>{{ device_bay.installed_device }}</strong> from <strong>{{ device_bay }}</strong>?</p>
{% endblock %}

View File

@@ -0,0 +1,51 @@
{% extends '_base.html' %}
{% load form_helpers %}
{% block title %}{% if devicebay.pk %}Editing {{ devicebay.device }} {{ devicebay }}{% else %}Add a Device Bay ({{ device }}){% endif %}{% endblock %}
{% block content %}
<form action="." method="post" class="form form-horizontal">
{% csrf_token %}
<div class="row">
<div class="col-md-6 col-md-offset-3">
{% if form.non_field_errors %}
<div class="panel panel-danger">
<div class="panel-heading"><strong>Errors</strong></div>
<div class="panel-body">
{{ form.non_field_errors }}
</div>
</div>
{% endif %}
<div class="panel panel-default">
<div class="panel-heading">
{% if poweroutlet.pk %}
<strong>Editing {{ devicebay }}</strong>
{% else %}
<strong>Add a Device Bay</strong>
{% endif %}
</div>
<div class="panel-body">
<div class="form-group">
<label class="col-md-3 control-label required">Device</label>
<div class="col-md-9">
<p class="form-control-static">{% if devicebay %}{{ devicebay.device }}{% else %}{{ device }}{% endif %}</p>
</div>
</div>
{% render_form form %}
</div>
</div>
<div class="form-group">
<div class="col-md-9 col-md-offset-3">
{% if devicebay.pk %}
<button type="submit" name="_update" class="btn btn-primary">Save</button>
{% else %}
<button type="submit" name="_create" class="btn btn-primary">Create</button>
<button type="submit" name="_addanother" class="btn btn-primary">Create and Add More</button>
{% endif %}
<a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
</div>
</div>
</div>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,46 @@
{% extends '_base.html' %}
{% load form_helpers %}
{% block title %}Populate {{ device_bay }}{% endblock %}
{% block content %}
<form action="." method="post" class="form form-horizontal">
{% csrf_token %}
<div class="row">
<div class="col-md-6 col-md-offset-3">
{% if form.non_field_errors %}
<div class="panel panel-danger">
<div class="panel-heading"><strong>Errors</strong></div>
<div class="panel-body">
{{ form.non_field_errors }}
</div>
</div>
{% endif %}
<div class="panel panel-default">
<div class="panel-heading">Populate {{ device_bay }}</div>
<div class="panel-body">
<div class="form-group">
<label class="col-md-3 control-label required">Parent Device</label>
<div class="col-md-9">
<p class="form-control-static">{{ device_bay.device }}</p>
</div>
</div>
<div class="form-group">
<label class="col-md-3 control-label required">Bay</label>
<div class="col-md-9">
<p class="form-control-static">{{ device_bay.name }}</p>
</div>
</div>
{% render_form form %}
</div>
</div>
<div class="form-group">
<div class="col-md-9 col-md-offset-3">
<button type="submit" name="_update" class="btn btn-primary">Save</button>
<a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
</div>
</div>
</div>
</div>
</form>
{% endblock %}

View File

@@ -14,8 +14,10 @@
</ol>
</div>
</div>
{% if perms.dcim.change_devicetype %}
{% if perms.dcim.change_devicetype or perms.dcim.delete_devicetype %}
<div class="pull-right">
{% if perms.dcim.change_devicetype %}
<a href="{% url 'dcim:devicetype_edit' pk=devicetype.pk %}" class="btn btn-warning">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span>
Edit this device type
@@ -26,8 +28,10 @@
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span>
Delete this device type
</a>
{% endif %}
</div>
{% endif %}
<h1>{{ devicetype }}</h1>
<div class="row">
<div class="col-md-6">
@@ -38,7 +42,7 @@
<table class="table table-hover panel-body">
<tr>
<td>Manufacturer</td>
<td>{{ devicetype.manufacturer }}</td>
<td><a href="{% url 'dcim:devicetype_list' %}?manufacturer={{ devicetype.manufacturer.slug }}">{{ devicetype.manufacturer }}</a></td>
</tr>
<tr>
<td>Model Name</td>
@@ -50,7 +54,13 @@
</tr>
<tr>
<td>Full Depth</td>
<td>{{ devicetype.is_full_depth|yesno|capfirst }}</td>
<td>
{% if devicetype.is_full_depth %}
<i class="glyphicon glyphicon-ok text-success" title="Yes"></i>
{% else %}
<i class="glyphicon glyphicon-remove text-danger" title="No"></i>
{% endif %}
</td>
</tr>
</table>
</div>
@@ -60,26 +70,84 @@
</div>
<table class="table table-hover panel-body">
<tr>
<td>Is a Console Server</td>
<td>{{ devicetype.is_console_server|yesno|capfirst }}</td>
<td class="text-right">
{% if devicetype.is_console_server %}
<i class="glyphicon glyphicon-ok text-success" title="Yes"></i>
{% else %}
<i class="glyphicon glyphicon-remove text-danger" title="No"></i>
{% endif %}
</td>
<td>
<strong>Console Server</strong><br />
<small class="text-muted">This device {% if devicetype.is_console_server %}has{% else %}does not have{% endif %} console server ports</small>
</td>
</tr>
<tr>
<td>Is a PDU</td>
<td>{{ devicetype.is_pdu|yesno|capfirst }}</td>
<td class="text-right">
{% if devicetype.is_pdu %}
<i class="glyphicon glyphicon-ok text-success" title="Yes"></i>
{% else %}
<i class="glyphicon glyphicon-remove text-danger" title="No"></i>
{% endif %}
</td>
<td>
<strong>PDU</strong><br />
<small class="text-muted">This device {% if devicetype.is_pdu %}has{% else %}does not have{% endif %} power outlets</small>
</td>
</tr>
<tr>
<td>Is a Network Device</td>
<td>{{ devicetype.is_network_device|yesno|capfirst }}</td>
<td class="text-right">
{% if devicetype.is_network_device %}
<i class="glyphicon glyphicon-ok text-success" title="Yes"></i>
{% else %}
<i class="glyphicon glyphicon-remove text-danger" title="No"></i>
{% endif %}
</td>
<td>
<strong>Network Device</strong><br />
<small class="text-muted">This device {% if devicetype.is_network_device %}has{% else %}does not have{% endif %} non-management network interfaces</small>
</td>
</tr>
<tr>
<td class="text-right">
{% if devicetype.subdevice_role == True %}
<label class="label label-primary">Parent</label>
{% elif devicetype.subdevice_role == False %}
<label class="label label-info">Child</label>
{% else %}
<label class="label label-default">None</label>
{% endif %}
</td>
<td>
<strong>Parent/Child</strong><br />
{% if devicetype.subdevice_role == True %}
<small class="text-muted">This device has device bays for mounting child devices</small>
{% elif devicetype.subdevice_role == False %}
<small class="text-muted">This device can only be mounted in a parent device</small>
{% else %}
<small class="text-muted">This device does not have device bays</small>
{% endif %}
</td>
</tr>
</table>
</div>
{% include 'dcim/inc/devicetype_component_table.html' with table=consoleport_table title='Console Ports' add_url='dcim:devicetype_add_consoleport' delete_url='dcim:devicetype_delete_consoleport' %}
{% include 'dcim/inc/devicetype_component_table.html' with table=powerport_table title='Power Ports' add_url='dcim:devicetype_add_powerport' delete_url='dcim:devicetype_delete_powerport' %}
{% include 'dcim/inc/devicetype_component_table.html' with table=mgmt_interface_table title='Management Interfaces' add_url='dcim:devicetype_add_interface' add_url_extra='?mgmt_only=1' delete_url='dcim:devicetype_delete_interface' %}
</div>
<div class="col-md-6">
{% if devicetype.is_parent_device %}
{% include 'dcim/inc/devicetype_component_table.html' with table=devicebay_table title='Device Bays' add_url='dcim:devicetype_add_devicebay' delete_url='dcim:devicetype_delete_devicebay' %}
{% endif %}
{% if devicetype.is_network_device %}
{% include 'dcim/inc/devicetype_component_table.html' with table=interface_table title='Interfaces' add_url='dcim:devicetype_add_interface' delete_url='dcim:devicetype_delete_interface' %}
{% endif %}
{% if devicetype.is_console_server %}
{% include 'dcim/inc/devicetype_component_table.html' with table=consoleserverport_table title='Console Server Ports' add_url='dcim:devicetype_add_consoleserverport' delete_url='dcim:devicetype_delete_consoleserverport' %}
{% endif %}
{% if devicetype.is_pdu %}
{% include 'dcim/inc/devicetype_component_table.html' with table=poweroutlet_table title='Power Outlets' add_url='dcim:devicetype_add_poweroutlet' delete_url='dcim:devicetype_delete_poweroutlet' %}
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -2,6 +2,7 @@
<td>
<i class="fa fa-fw fa-keyboard-o"></i> {{ cp.name }}
</td>
<td></td>
{% if cp.cs_port %}
<td>
<a href="{% url 'dcim:device' pk=cp.cs_port.device.pk %}">{{ cp.cs_port.device }}</a>
@@ -10,7 +11,9 @@
{{ cp.cs_port.name }}
</td>
{% else %}
<td colspan="2">Not connected</td>
<td colspan="2">
<span class="text-muted">Not connected</span>
</td>
{% endif %}
<td class="text-right">
{% if perms.dcim.change_consoleport %}

View File

@@ -10,7 +10,9 @@
{{ csp.connected_console.name }}
</td>
{% else %}
<td colspan="2">Not connected</td>
<td colspan="2">
<span class="text-muted">Not connected</span>
</td>
{% endif %}
<td class="text-right">
{% if perms.dcim.change_consoleserverport %}

View File

@@ -5,6 +5,10 @@
<li><a href="{% url 'dcim:site' slug=device.rack.site.slug %}">{{ device.rack.site }}</a></li>
<li><a href="{% url 'dcim:rack_list' %}?site={{ device.rack.site.slug }}">Racks</a></li>
<li><a href="{% url 'dcim:rack' pk=device.rack.pk %}">{{ device.rack }}</a></li>
{% if device.parent_bay %}
<li><a href="{% url 'dcim:device' pk=device.parent_bay.device.pk %}">{{ device.parent_bay.device }}</a></li>
<li>{{ device.parent_bay.name }}</li>
{% endif %}
<li>{{ device }}</li>
</ol>
{% endif %}

View File

@@ -0,0 +1,44 @@
<tr>
<td>
<i class="fa fa-fw fa-{% if devicebay.installed_device %}dot-circle-o{% else %}circle-o{% endif %}"></i> {{ devicebay.name }}
</td>
{% if devicebay.installed_device %}
<td>
<a href="{% url 'dcim:device' pk=devicebay.installed_device.pk %}">{{ devicebay.installed_device }}</a>
</td>
<td>
<span>{{ devicebay.installed_device.device_type }}</span>
</td>
{% else %}
<td colspan="2">
<span class="text-muted">Vacant</span>
</td>
{% endif %}
<td class="text-right">
{% if perms.dcim.change_devicebay %}
{% if devicebay.installed_device %}
<a href="{% url 'dcim:devicebay_depopulate' pk=devicebay.pk %}" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-remove" aria-hidden="true" title="Remove device"></i>
</a>
{% else %}
<a href="{% url 'dcim:devicebay_populate' pk=devicebay.pk %}" class="btn btn-success btn-xs">
<i class="glyphicon glyphicon-plus" aria-hidden="true" title="Install device"></i>
</a>
{% endif %}
<a href="{% url 'dcim:devicebay_edit' pk=devicebay.pk %}" class="btn btn-info btn-xs">
<i class="glyphicon glyphicon-pencil" aria-hidden="true" title="Edit device bay"></i>
</a>
{% endif %}
{% if perms.dcim.delete_devicebay %}
{% if devicebay.installed_device %}
<button class="btn btn-danger btn-xs" disabled="disabled">
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</button>
{% else %}
<a href="{% url 'dcim:devicebay_delete' pk=devicebay.pk %}" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete device bay"></i>
</a>
{% endif %}
{% endif %}
</td>
</tr>

View File

@@ -5,6 +5,9 @@
<i class="fa fa-fw fa-comment-o" title="{{ iface.description }}"></i>
{% endif %}
</td>
<td>
<small>{{ iface.mac_address|default:'' }}</small>
</td>
{% if not iface.is_physical %}
<td colspan="2">Virtual</td>
{% elif iface.connection %}
@@ -21,7 +24,9 @@
<a href="{% url 'circuits:circuit' pk=iface.circuit.pk %}">{{ iface.circuit }}</a>
</td>
{% else %}
<td colspan="2">Not connected</td>
<td colspan="2">
<span class="text-muted">Not connected</span>
</td>
{% endif %}
<td class="text-right">
{% if iface.circuit or iface.connection %}

View File

@@ -4,7 +4,7 @@
</td>
<td>{{ ip.interface }}</td>
<td>
{% if device.primary_ip == ip %}
{% if device.primary_ip4 == ip or device.primary_ip6 == ip %}
<span class="label label-success">Primary</span>
{% endif %}
</td>

View File

@@ -10,7 +10,9 @@
{{ po.connected_port.name }}
</td>
{% else %}
<td colspan="2">Not connected</td>
<td colspan="2">
<span class="text-muted">Not connected</span>
</td>
{% endif %}
<td class="text-right">
{% if perms.dcim.change_poweroutlet %}

View File

@@ -2,6 +2,7 @@
<td>
<i class="fa fa-fw fa-bolt"></i> {{ pp.name }}
</td>
<td></td>
{% if pp.power_outlet %}
<td>
<a href="{% url 'dcim:device' pk=pp.power_outlet.device.pk %}">{{ pp.power_outlet.device }}</a>
@@ -10,7 +11,9 @@
{{ pp.power_outlet.name }}
</td>
{% else %}
<td colspan="2">Not connected</td>
<td colspan="2">
<span class="text-muted">Not connected</span>
</td>
{% endif %}
<td class="text-right">
{% if perms.dcim.change_powerport %}

View File

@@ -4,7 +4,10 @@
{% csrf_token %}
<div class="panel panel-default">
<div class="panel-heading">
<a href="{% url add_url pk=devicetype.pk %}" class="btn btn-primary btn-xs pull-right"><span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add {{ title }}</a>
<a href="{% url add_url pk=devicetype.pk %}{{ add_url_extra }}" class="btn btn-primary btn-xs pull-right">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
Add {{ title }}
</a>
<strong>{{ title }}</strong>
</div>
{% render_table table 'table.html' %}

View File

@@ -8,6 +8,14 @@
<h1>Interface Connections Import</h1>
<div class="row">
<div class="col-md-6">
{% if form.non_field_errors %}
<div class="panel panel-danger">
<div class="panel-heading"><strong>Errors</strong></div>
<div class="panel-body">
{{ form.non_field_errors }}
</div>
</div>
{% endif %}
<form action="." method="post" class="form">
{% csrf_token %}
{% render_form form %}

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