Compare commits

...

104 Commits

Author SHA1 Message Date
Jeremy Stretch
ce26b566a4 Merge pull request #939 from digitalocean/develop
Release v1.9.0-r1
2017-03-03 11:28:02 -05:00
Jeremy Stretch
ee2d0b963d Release v1.9.0-r1 2017-03-03 11:26:31 -05:00
Jeremy Stretch
f051c0e564 Fixes #935: Fix form validation error when connecting an interface using live search 2017-03-03 11:19:19 -05:00
Jeremy Stretch
03d3bbcddb Fixes #938: Provider view yields an error if one or more circuits is assigned to a tenant 2017-03-03 10:47:16 -05:00
Jeremy Stretch
06cafb09b3 Fixes #937: Region assignment should be optional when creating a site 2017-03-03 10:44:07 -05:00
Jeremy Stretch
4670929953 Added GitHub issue/PR templates; updated CONTRIBUTING policy 2017-03-02 15:30:14 -05:00
Jeremy Stretch
366e2e7a94 Post-release version bump 2017-03-02 13:30:45 -05:00
Jeremy Stretch
0e14bc1e02 Merge pull request #933 from digitalocean/develop
Release v1.9.0
2017-03-02 13:27:10 -05:00
Jeremy Stretch
e5f05ca9be Release v1.9.0 2017-03-02 13:22:29 -05:00
Jeremy Stretch
d08522408a Documentation refresh 2017-03-02 13:21:56 -05:00
Jeremy Stretch
cc31c8fc33 Closes #894: Expand device name max length to 64 characters 2017-03-02 10:11:54 -05:00
Jeremy Stretch
db60e8868c Closes #901: Support for filtering prefixes and IP addresses by mask length 2017-03-01 14:23:52 -05:00
Jeremy Stretch
1adae67dd7 Closes #927: Upgrade to django-filter 1.0 2017-03-01 13:09:19 -05:00
Jeremy Stretch
5ad3044314 Closes #862: Show both IPv6 and IPv4 primary IPs in device list 2017-02-28 16:46:44 -05:00
Jeremy Stretch
9313ba08ed Implemented recursive regions with django-mptt 2017-02-28 14:15:15 -05:00
Jeremy Stretch
f3b9930dea Initial work on regions 2017-02-28 12:11:43 -05:00
Jeremy Stretch
5520144ff4 Fixed permissions for inclusion of interface bulk edit form 2017-02-27 17:19:30 -05:00
Jeremy Stretch
2b9ea58c86 Tweaked interface LAG validation 2017-02-27 17:13:58 -05:00
Jeremy Stretch
c6970e1998 Closes #105: Interface groups (#919)
* Initial work on interface groups

* Simplify to a single LAG form factor

* Correct interface serializer

* Allow for bulk editing of interface LAG

* Additional LAG interface validation

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

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

* commit1

* commit2

* commit3

* Add migration file for VLAN&VLAN group

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

* Updated device component connection forms

* Updated IP address assignment form

* Updated circuit termination form

* Formatting cleanup

* Fixed tests
2017-02-17 14:48:00 -05:00
Jeremy Stretch
9d44d5d4e7 Fixes #897: Fixed power connections CSV export 2017-02-16 15:17:13 -05:00
Jeremy Stretch
e8896fe238 Closes #898: Expand circuits list in provider view 2017-02-16 15:13:35 -05:00
Jeremy Stretch
181539651f Rack reservations (#900)
* Initial work on rack reservations

* Added views for rack reservations

* Implemented ArrayFieldSelectMultiple form widget

* Implemented API endpoints for rack reservations

* Tweaked the database migration
2017-02-16 13:46:58 -05:00
Jeremy Stretch
b69564f5c9 Exposed the request to ObjectEditView's alter_obj() 2017-02-15 15:03:31 -05:00
Jeremy Stretch
c26e00b5bd Merge pull request #850 from mryauch/patch-1
Update upgrading.md
2017-02-14 15:45:06 -05:00
Jeremy Stretch
dc606645fd Fixes #884: Preserve selected rack unit when changing a device's rack face 2017-02-09 23:27:12 -05:00
Jeremy Stretch
1e1dd8c668 Post-release version bump 2017-02-03 13:59:26 -05:00
Jeremy Stretch
ce6796ed9b Merge pull request #870 from digitalocean/develop
Release v1.8.4
2017-02-03 13:59:02 -05:00
Jeremy Stretch
585e08eb95 Release v1.8.4 2017-02-03 13:55:32 -05:00
Jeremy Stretch
d817990283 Fixes #865: Fix server error when attempting to delete a protected object parent (Python 3) 2017-02-01 12:09:59 -05:00
Jeremy Stretch
9905099a71 Fixes #854: Check whether object still exists before attempting to resolve its URL 2017-02-01 11:59:47 -05:00
Jeremy Stretch
0eba5a0de3 Fixes #851: Resolve encoding issues during import/export with Python 3 2017-02-01 11:49:54 -05:00
Jeremy Stretch
5eb3c1a67b Removed deprecated base_path Swagger setting 2017-02-01 10:48:36 -05:00
Jeremy Stretch
b370375414 Fixes #861: Avoid overwriting device primary IP assignment from alternate family during bulk import of IP addresses 2017-01-31 17:25:44 -05:00
Jeremy Stretch
8536f6c163 Closes #856: Strip whitespace from fields during CSV import 2017-01-31 16:54:13 -05:00
Jeremy Stretch
f4f41a5985 Fixes #859: Fix Javascript for connection status toggle button 2017-01-31 09:41:25 -05:00
Jeremy Stretch
af3c9eaec1 Fixes #854: Correct processing of get_return_url() in ObjectDeleteView 2017-01-30 12:13:24 -05:00
Matthew Yauch
c91f41e984 Update upgrading.md
Added directive to copy the LDAP configuration if in use.  I upgraded a whole two versions before realizing my LDAP was broken.
2017-01-26 15:28:11 -08:00
Jeremy Stretch
b8b2ea7ccb Post-release version bump 2017-01-26 14:00:08 -05:00
Jeremy Stretch
c90cecc2fb Merge pull request #849 from digitalocean/develop
Release v1.8.3
2017-01-26 13:58:52 -05:00
Jeremy Stretch
b2ef7bb104 Release v1.8.3 2017-01-26 13:57:00 -05:00
Jeremy Stretch
5d5d4ac714 Fixes #845: Fix missing edit/delete buttons on object tables for non-superusers 2017-01-26 13:20:56 -05:00
dav3860
b3b96e5e10 Support for comma in interfaces and ip addresses bulk creation (#833)
* Added support for comma in interfaces and ip addresses bulk creation

* fixed PEP8 style

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

* update docs and trigger Travis

* fix some of the tests

* fix all python3 errors

* change env calls to just python

* add @python_2_unicode_compatible decorator to models for python2 compatibility

* switch netbox.configuration to from netbox import configuration
2017-01-23 16:44:29 -05:00
Jeremy Stretch
1bddd038fe Fixes #840: Correct API path resolution for secrets when BASE_PATH is configured 2017-01-23 16:25:05 -05:00
Jeremy Stretch
d36923e47d Fixes #817: Update last_updated time of a circuit when editing a child termination 2017-01-23 15:31:41 -05:00
Jeremy Stretch
476cbf17f6 Closes #820: Add VLAN column to parent prefixes table on IP address view 2017-01-23 14:23:42 -05:00
Jeremy Stretch
91d50b9627 Closes #836: Add 'deprecated' status for IP addresses 2017-01-23 14:12:43 -05:00
Jeremy Stretch
52420945b2 Standardized naming of return_url for all object views 2017-01-23 14:07:26 -05:00
Jeremy Stretch
b70eca7661 Fixes #830: Redirect user to device view after editing a device component 2017-01-23 12:14:12 -05:00
Jeremy Stretch
39d083eae7 Re-implemented method for bulk editing/deleting all objects within a filtered queryset 2017-01-20 16:42:11 -05:00
Jeremy Stretch
3bfc1ebcea Post-release version bump 2017-01-18 16:23:52 -05:00
Jeremy Stretch
b6bbcb0609 Merge pull request #814 from digitalocean/develop
Release v1.8.2
2017-01-18 16:23:28 -05:00
Jeremy Stretch
6121f97ca9 Release v1.8.2 2017-01-18 16:19:45 -05:00
Jeremy Stretch
74e48fc490 PEP8 fixes 2017-01-18 14:43:46 -05:00
Jeremy Stretch
28a9307f9f Deprecated use_obj_view in favor of get_return_url() 2017-01-18 14:34:17 -05:00
Jeremy Stretch
cdccc3a47f Ditched get_parent_url() model method in favor of overrideable get_return_url() view method 2017-01-18 14:07:46 -05:00
Jeremy Stretch
3eb969de0c Standardized the use of return_url for ObjectDeleteView 2017-01-18 13:30:19 -05:00
Jeremy Stretch
9ff59ab686 Closes #760: Redirect user back to device view after deleting an assigned IP address 2017-01-18 12:25:07 -05:00
Jeremy Stretch
fc7f88d2a2 Regression fix: order_naturally() must come first in the queryset definition 2017-01-18 11:55:48 -05:00
Jeremy Stretch
769537fe98 Fixes #810: Suppress unique IP validation on invalid IP addresses and prefixes 2017-01-18 09:55:57 -05:00
Jeremy Stretch
f8a4f1b24f Closes #797: Add description column to VLANs table 2017-01-17 16:06:19 -05:00
Jeremy Stretch
7f3b358571 Fixes #807: Redirect user back to form when adding IP addresses in bulk and "create and add another" is clicked 2017-01-17 15:46:43 -05:00
Jeremy Stretch
c264281530 Add an empty label (global) to IPAddressBulkAddForm VRF field 2017-01-17 15:33:55 -05:00
Jeremy Stretch
b3f20aa233 Closes #783: Add a description field to the Circuit model 2017-01-17 15:18:03 -05:00
Jeremy Stretch
07997b24ca Fixes #785: Trigger validation error when importing a prefix assigned to a nonexistent VLAN 2017-01-17 15:01:30 -05:00
Jeremy Stretch
03859d7287 Closes #803: Clarify that no child objects are deleted when deleting a prefix 2017-01-17 14:52:39 -05:00
Jeremy Stretch
0ad2670822 Closes #805: Linkify site column in device table 2017-01-17 14:46:29 -05:00
Jeremy Stretch
ab706d2440 Follow-up to #804 2017-01-17 12:32:54 -05:00
Jeremy Stretch
398faf518c Merge pull request #804 from digitalocean/prefix-unique
Enforce Global Unique on Prefixes
2017-01-17 10:28:00 -05:00
Zach Moody
edf29e7b9b moved duplicates() method to model instead of manager. 2017-01-16 18:14:34 -06:00
Zach Moody
485a21f13e cleaned up IPAddress clean() to be more like Prefix's 2017-01-16 16:52:03 -06:00
Zach Moody
eedec192ba Added model tests for duplicate prefix and IPs. 2017-01-16 16:40:06 -06:00
Zach Moody
cfaf8b9157 added duplicates() method to IPAddress and Prefix model managers.
refactored condition on IPAddress and Prefix clean method to use new
manager method.
2017-01-16 16:28:04 -06:00
Jeremy Stretch
98e2145b52 Merge pull request #796 from rburkholder/rburkholder-patch-TenantSerializer-description
rburkholder - patch - tenant serialiser - add description field to output
2017-01-13 09:36:33 -05:00
Jeremy Stretch
466c505bb8 Corrected PEP8 errors 2017-01-13 09:30:59 -05:00
Raymond P. Burkholder
97c0f23c67 Add description field to TenantSerializer
This might be just an oversight.  Other data models do include the description in their serialisers.  The API produces the description field with this change.
2017-01-13 08:49:43 -04:00
Jeremy Stretch
424c2a59d6 Table rendering optimizations 2017-01-06 16:50:57 -05:00
Jeremy Stretch
c9e7c12463 Closes #284: Added interface_ordering field to DeviceType 2017-01-06 12:59:49 -05:00
Jeremy Stretch
2ef1e623a3 Merge pull request #781 from zevlag/patch-1
Update CONTRIBUTING.md to replace Reddit with Google Groups
2017-01-05 21:55:16 -05:00
Josh Galvez
1486a8901a Update CONTRIBUTING.md
Change Reddit to Google Groups Mailing List
2017-01-05 16:02:05 -07:00
Jeremy Stretch
73ae87aa57 Updated circuits documentation 2017-01-05 17:06:30 -05:00
Jeremy Stretch
ac72e90dcc Fixes #778: Refactored order_interfaces() to fix InterfaceTemplate ordering within a table 2017-01-05 16:12:07 -05:00
Jeremy Stretch
dbf9840b26 Corrected permissions for device component form rendering 2017-01-05 15:37:15 -05:00
Jeremy Stretch
09fe328c3f Standardized template names 2017-01-05 15:31:41 -05:00
Jeremy Stretch
381eb664cf Added alternative installations section 2017-01-04 17:16:04 -05:00
Jeremy Stretch
23c6451524 Fixes #776: Prevent circuits from appearing twice while searching 2017-01-04 16:56:28 -05:00
Jeremy Stretch
99cd78cbbf Post-release version bump 2017-01-04 15:31:10 -05:00
162 changed files with 3420 additions and 1418 deletions

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

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

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

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

2
.gitignore vendored
View File

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

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ NetBox is an IP address management (IPAM) and data center infrastructure managem
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/).
The complete documentation for NetBox can be found at [Read the Docs](http://netbox.readthedocs.io/en/stable/).
Questions? Comments? Please subscribe to [the netbox-discuss mailing list](https://groups.google.com/forum/#!forum/netbox-discuss), or join us on IRC in **#netbox** on **irc.freenode.net**!
@@ -25,6 +25,9 @@ Questions? Comments? Please subscribe to [the netbox-discuss mailing list](https
# Installation
Please see [the documentation](http://netbox.readthedocs.io/en/latest/) for instructions on installing NetBox.
Please see [the documentation](http://netbox.readthedocs.io/en/stable/) for instructions on installing NetBox. To upgrade NetBox, please download the [latest release](https://github.com/digitalocean/netbox/releases) and run `upgrade.sh`.
To upgrade NetBox, please download the [latest release](https://github.com/digitalocean/netbox/releases) and run `upgrade.sh`.
## Alternative Installations
* [Docker container](http://netbox.readthedocs.io/en/stable/installation/docker/)
* [Heroku deployment](https://heroku.com/deploy?template=https://github.com/BILDQUADRAT/netbox/tree/heroku) (via [@mraerino](https://github.com/BILDQUADRAT/netbox/tree/heroku))

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ This section entails features of NetBox which are not crucial to its primary fun
# Custom Fields
Each object in NetBox is represented in the database as a discrete table, and each attribute of an object exists as a column within its table. For example, sites are stored in the `dcim_site` table, which has columns named `name`, `facility`, `physical_address` and so on. As new attributes are added to objects throughout the development of NetBox, tables are expanded to include new rows.
Each object in NetBox is represented in the database as a discrete table, and each attribute of an object exists as a column within its table. For example, sites are stored in the `dcim_site` table, which has columns named `name`, `facility`, `physical_address`, and so on. As new attributes are added to objects throughout the development of NetBox, tables are expanded to include new rows.
However, some users might want to associate with objects attributes that are somewhat esoteric in nature, and that would not make sense to include in the core NetBox database schema. For instance, suppose your organization needs to associate each device with a ticket number pointing to the support ticket that was opened to have it installed. This is certainly a legitimate use for NetBox, but it's perhaps not a common enough need to warrant expanding the internal data schema. Instead, you can create a custom field to hold this data.
@@ -33,7 +33,15 @@ NetBox allows users to define custom templates that can be used when exporting o
Each export template is associated with a certain type of object. For instance, if you create an export template for VLANs, your custom template will appear under the "Export" button on the VLANs list.
Export templates are written in [Django's template language](https://docs.djangoproject.com/en/1.9/ref/templates/language/), which is very similar to Jinja2. The list of objects returned from the database is stored in the `queryset` variable. Typically, you'll want to iterate through this list using a for loop.
Export templates are written in [Django's template language](https://docs.djangoproject.com/en/1.9/ref/templates/language/), which is very similar to Jinja2. The list of objects returned from the database is stored in the `queryset` variable, which you'll typically want to iterate through using a `for` loop. Object properties can be access by name. For example:
```
{% for rack in queryset %}
Rack: {{ rack.name }}
Site: {{ rack.site.name }}
Height: {{ rack.u_height }}U
{% endfor %}
```
To access custom fields of an object within a template, use the `cf` attribute. For example, `{{ obj.cf.color }}` will return the value (if any) for a custom field named `color` on `obj`.
@@ -44,10 +52,10 @@ A MIME type and file extension can optionally be defined for each export templat
Here's an example device export template that will generate a simple Nagios configuration from a list of devices.
```
{% for d in queryset %}{% if d.status and d.primary_ip %}define host{
{% for device in queryset %}{% if device.status and device.primary_ip %}define host{
use generic-switch
host_name {{ d.name }}
address {{ d.primary_ip.address.ip }}
host_name {{ device.name }}
address {{ device.primary_ip.address.ip }}
}
{% endif %}{% endfor %}
```
@@ -74,9 +82,9 @@ define host{
# Graphs
NetBox does not generate graphs itself. This feature allows you to embed contextual graphs from an external resources inside certain NetBox views. Each embedded graph must be defined with the following parameters:
NetBox does not have the ability to generate graphs natively, but this feature allows you to embed contextual graphs from an external resources (such as a monitoring system) inside the site, provider, and interface views. Each embedded graph must be defined with the following parameters:
* **Type:** Interface, provider, or site. This determines where the graph will be displayed.
* **Type:** Site, provider, or interface. This determines in which view the graph will be displayed.
* **Weight:** Determines the order in which graphs are displayed (lower weights are displayed first). Graphs with equal weights will be ordered alphabetically by name.
* **Name:** The title to display above the graph.
* **Source URL:** The source of the image to be embedded. The associated object will be available as a template variable named `obj`.
@@ -86,7 +94,7 @@ NetBox does not generate graphs itself. This feature allows you to embed context
NetBox can generate simple topology maps from the physical network connections recorded in its database. First, you'll need to create a topology map definition under the admin UI at Extras > Topology Maps.
Each topology map is associated with a site. A site can have multiple topology maps, which might each illustrate a different aspect of its infrastructure (for example, production versus backend connectivity).
Each topology map is associated with a site. A site can have multiple topology maps, which might each illustrate a different aspect of its infrastructure (for example, production versus backend infrastructure).
To define the scope of a topology map, decide which devices you want to include. The map will only include interface connections with both points terminated on an included device. Specify the devices to include in the **device patterns** field by entering a list of [regular expressions](https://en.wikipedia.org/wiki/Regular_expression) matching device names. For example, if you wanted to include "mgmt-switch1" through "mgmt-switch99", you might use the regex `mgmt-switch\d+`.

View File

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

View File

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

View File

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

View File

@@ -2,12 +2,29 @@
**Debian/Ubuntu**
Python 3:
```no-highlight
# apt-get install -y python3 python3-dev python3-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev
```
Python 2:
```no-highlight
# apt-get install -y python2.7 python-dev python-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev
```
**CentOS/RHEL**
Python 3:
```no-highlight
# yum install -y epel-release
# yum install -y gcc python3 python3-devel python3-pip libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel
```
Python 2:
```no-highlight
# yum install -y epel-release
# yum install -y gcc python2 python-devel python-pip libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel

View File

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

View File

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

View File

@@ -62,7 +62,8 @@ class CircuitSerializer(CustomFieldSerializer, serializers.ModelSerializer):
class Meta:
model = Circuit
fields = ['id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'comments', 'terminations', 'custom_fields']
fields = ['id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
'terminations', 'custom_fields']
class CircuitNestedSerializer(CircuitSerializer):

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

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

View File

@@ -11,8 +11,8 @@ from .models import Provider, Circuit, CircuitType
class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
q = django_filters.CharFilter(
method='search',
label='Search',
)
site_id = django_filters.ModelMultipleChoiceFilter(
@@ -31,7 +31,9 @@ class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet):
model = Provider
fields = ['name', 'account', 'asn']
def search(self, queryset, value):
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(account__icontains=value) |
@@ -40,8 +42,8 @@ class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet):
class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
q = django_filters.CharFilter(
method='search',
label='Search',
)
provider_id = django_filters.ModelMultipleChoiceFilter(
@@ -93,10 +95,13 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
model = Circuit
fields = ['install_date']
def search(self, queryset, value):
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(cid__icontains=value) |
Q(terminations__xconnect_id__icontains=value) |
Q(terminations__pp_info__icontains=value) |
Q(description__icontains=value) |
Q(comments__icontains=value)
)
).distinct()

View File

@@ -1,7 +1,7 @@
from django import forms
from django.db.models import Count
from dcim.models import Site, Device, Interface, Rack, IFACE_FF_VIRTUAL
from dcim.models import Site, Device, Interface, Rack, VIRTUAL_IFACE_TYPES
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
from tenancy.models import Tenant
from utilities.forms import (
@@ -62,7 +62,9 @@ class ProviderBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Provider
q = forms.CharField(required=False, label='Search')
site = FilterChoiceField(queryset=Site.objects.all(), to_field_name='slug')
asn = forms.IntegerField(required=False, label='ASN')
#
@@ -86,7 +88,7 @@ class CircuitForm(BootstrapMixin, CustomFieldForm):
class Meta:
model = Circuit
fields = ['cid', 'type', 'provider', 'tenant', 'install_date', 'commit_rate', 'comments']
fields = ['cid', 'type', 'provider', 'tenant', 'install_date', 'commit_rate', 'description', 'comments']
help_texts = {
'cid': "Unique circuit ID",
'install_date': "Format: YYYY-MM-DD",
@@ -104,7 +106,7 @@ class CircuitFromCSVForm(forms.ModelForm):
class Meta:
model = Circuit
fields = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate']
fields = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description']
class CircuitImportForm(BootstrapMixin, BulkImportForm):
@@ -117,22 +119,33 @@ class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
provider = forms.ModelChoiceField(queryset=Provider.objects.all(), required=False)
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
commit_rate = forms.IntegerField(required=False, label='Commit rate (Kbps)')
description = forms.CharField(max_length=100, required=False)
comments = CommentField(widget=SmallTextarea)
class Meta:
nullable_fields = ['tenant', 'commit_rate', 'comments']
nullable_fields = ['tenant', 'commit_rate', 'description', 'comments']
class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Circuit
type = FilterChoiceField(queryset=CircuitType.objects.annotate(filter_count=Count('circuits')),
to_field_name='slug')
provider = FilterChoiceField(queryset=Provider.objects.annotate(filter_count=Count('circuits')),
to_field_name='slug')
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('circuits')), to_field_name='slug',
null_option=(0, 'None'))
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('circuit_terminations')),
to_field_name='slug')
q = forms.CharField(required=False, label='Search')
type = FilterChoiceField(
queryset=CircuitType.objects.annotate(filter_count=Count('circuits')),
to_field_name='slug'
)
provider = FilterChoiceField(
queryset=Provider.objects.annotate(filter_count=Count('circuits')),
to_field_name='slug'
)
tenant = FilterChoiceField(
queryset=Tenant.objects.annotate(filter_count=Count('circuits')),
to_field_name='slug',
null_option=(0, 'None')
)
site = FilterChoiceField(
queryset=Site.objects.annotate(filter_count=Count('circuit_terminations')),
to_field_name='slug'
)
#
@@ -140,19 +153,49 @@ class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
#
class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.Select(attrs={'filter-for': 'rack'}))
rack = forms.ModelChoiceField(queryset=Rack.objects.all(), required=False, label='Rack',
widget=APISelect(api_url='/api/dcim/racks/?site_id={{site}}',
attrs={'filter-for': 'device'}))
device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, label='Device',
widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}',
display_field='display_name', attrs={'filter-for': 'interface'}))
livesearch = forms.CharField(required=False, label='Device', widget=Livesearch(
query_key='q', query_url='dcim-api:device_list', field_to_update='device')
site = forms.ModelChoiceField(
queryset=Site.objects.all(),
widget=forms.Select(
attrs={'filter-for': 'rack'}
)
)
rack = forms.ModelChoiceField(
queryset=Rack.objects.all(),
required=False,
label='Rack',
widget=APISelect(
api_url='/api/dcim/racks/?site_id={{site}}',
attrs={'filter-for': 'device', 'nullable': 'true'}
)
)
device = forms.ModelChoiceField(
queryset=Device.objects.all(),
required=False,
label='Device',
widget=APISelect(
api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}',
display_field='display_name',
attrs={'filter-for': 'interface'}
)
)
livesearch = forms.CharField(
required=False,
label='Device',
widget=Livesearch(
query_key='q',
query_url='dcim-api:device_list',
field_to_update='device'
)
)
interface = forms.ModelChoiceField(
queryset=Interface.objects.all(),
required=False,
label='Interface',
widget=APISelect(
api_url='/api/dcim/devices/{{device}}/interfaces/?type=physical',
disabled_indicator='is_connected'
)
)
interface = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Interface',
widget=APISelect(api_url='/api/dcim/devices/{{device}}/interfaces/?type=physical',
disabled_indicator='is_connected'))
class Meta:
model = CircuitTermination
@@ -194,14 +237,18 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
# Limit interface choices
if self.is_bound and self.data.get('device'):
interfaces = Interface.objects.filter(device=self.data['device'])\
.exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit_termination', 'connected_as_a',
'connected_as_b')
interfaces = Interface.objects.filter(device=self.data['device']).exclude(
form_factor__in=VIRTUAL_IFACE_TYPES
).select_related(
'circuit_termination', 'connected_as_a', 'connected_as_b'
)
self.fields['interface'].widget.attrs['initial'] = self.data.get('interface')
elif self.initial.get('device'):
interfaces = Interface.objects.filter(device=self.initial['device'])\
.exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit_termination', 'connected_as_a',
'connected_as_b')
interfaces = Interface.objects.filter(device=self.initial['device']).exclude(
form_factor__in=VIRTUAL_IFACE_TYPES
).select_related(
'circuit_termination', 'connected_as_a', 'connected_as_b'
)
self.fields['interface'].widget.attrs['initial'] = self.initial.get('interface')
else:
interfaces = []

View File

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2017-01-17 20:08
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('circuits', '0006_terminations'),
]
operations = [
migrations.AddField(
model_name='circuit',
name='description',
field=models.CharField(blank=True, max_length=100),
),
]

View File

@@ -1,6 +1,7 @@
from django.contrib.contenttypes.fields import GenericRelation
from django.core.urlresolvers import reverse
from django.db import models
from django.utils.encoding import python_2_unicode_compatible
from dcim.fields import ASNField
from extras.models import CustomFieldModel, CustomFieldValue
@@ -33,6 +34,7 @@ def humanize_speed(speed):
return '{} Kbps'.format(speed)
@python_2_unicode_compatible
class Provider(CreatedUpdatedModel, CustomFieldModel):
"""
Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model
@@ -51,7 +53,7 @@ class Provider(CreatedUpdatedModel, CustomFieldModel):
class Meta:
ordering = ['name']
def __unicode__(self):
def __str__(self):
return self.name
def get_absolute_url(self):
@@ -67,6 +69,7 @@ class Provider(CreatedUpdatedModel, CustomFieldModel):
])
@python_2_unicode_compatible
class CircuitType(models.Model):
"""
Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named
@@ -78,13 +81,14 @@ class CircuitType(models.Model):
class Meta:
ordering = ['name']
def __unicode__(self):
def __str__(self):
return self.name
def get_absolute_url(self):
return "{}?type={}".format(reverse('circuits:circuit_list'), self.slug)
@python_2_unicode_compatible
class Circuit(CreatedUpdatedModel, CustomFieldModel):
"""
A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple
@@ -97,6 +101,7 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
tenant = models.ForeignKey(Tenant, related_name='circuits', blank=True, null=True, on_delete=models.PROTECT)
install_date = models.DateField(blank=True, null=True, verbose_name='Date installed')
commit_rate = models.PositiveIntegerField(blank=True, null=True, verbose_name='Commit rate (Kbps)')
description = models.CharField(max_length=100, blank=True)
comments = models.TextField(blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
@@ -104,7 +109,7 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
ordering = ['provider', 'cid']
unique_together = ['provider', 'cid']
def __unicode__(self):
def __str__(self):
return u'{} {}'.format(self.provider, self.cid)
def get_absolute_url(self):
@@ -118,6 +123,7 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
self.tenant.name if self.tenant else None,
self.install_date.isoformat() if self.install_date else None,
self.commit_rate,
self.description,
])
def _get_termination(self, side):
@@ -139,6 +145,7 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
commit_rate_human.admin_order_field = 'commit_rate'
@python_2_unicode_compatible
class CircuitTermination(models.Model):
circuit = models.ForeignKey('Circuit', related_name='terminations', on_delete=models.CASCADE)
term_side = models.CharField(max_length=1, choices=TERM_SIDE_CHOICES, verbose_name='Termination')
@@ -154,12 +161,9 @@ class CircuitTermination(models.Model):
ordering = ['circuit', 'term_side']
unique_together = ['circuit', 'term_side']
def __unicode__(self):
def __str__(self):
return u'{} (Side {})'.format(self.circuit, self.get_term_side_display())
def get_parent_url(self):
return self.circuit.get_absolute_url()
def get_peer_termination(self):
peer_side = 'Z' if self.term_side == 'A' else 'A'
try:

View File

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

View File

@@ -60,9 +60,8 @@ class CircuitTable(BaseTable):
args=[Accessor('termination_a.site.slug')])
z_side = tables.LinkColumn('dcim:site', accessor=Accessor('termination_z.site'), orderable=False,
args=[Accessor('termination_z.site.slug')])
commit_rate = tables.Column(accessor=Accessor('commit_rate_human'), order_by=Accessor('commit_rate'),
verbose_name='Commit Rate')
description = tables.Column(verbose_name='Description')
class Meta(BaseTable.Meta):
model = Circuit
fields = ('pk', 'cid', 'type', 'provider', 'tenant', 'a_side', 'z_side', 'commit_rate')
fields = ('pk', 'cid', 'type', 'provider', 'tenant', 'a_side', 'z_side', 'description')

View File

@@ -1,6 +1,7 @@
from django.contrib import messages
from django.contrib.auth.decorators import permission_required
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.urlresolvers import reverse
from django.db import transaction
from django.db.models import Count
from django.shortcuts import get_object_or_404, redirect, render
@@ -24,14 +25,14 @@ class ProviderListView(ObjectListView):
filter = filters.ProviderFilter
filter_form = forms.ProviderFilterForm
table = tables.ProviderTable
edit_permissions = ['circuits.change_provider', 'circuits.delete_provider']
template_name = 'circuits/provider_list.html'
def provider(request, slug):
provider = get_object_or_404(Provider, slug=slug)
circuits = Circuit.objects.filter(provider=provider)
circuits = Circuit.objects.filter(provider=provider).select_related('type', 'tenant')\
.prefetch_related('terminations__site')
show_graphs = Graph.objects.filter(type=GRAPH_TYPE_PROVIDER).exists()
return render(request, 'circuits/provider.html', {
@@ -46,13 +47,13 @@ class ProviderEditView(PermissionRequiredMixin, ObjectEditView):
model = Provider
form_class = forms.ProviderForm
template_name = 'circuits/provider_edit.html'
obj_list_url = 'circuits:provider_list'
default_return_url = 'circuits:provider_list'
class ProviderDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'circuits.delete_provider'
model = Provider
redirect_url = 'circuits:provider_list'
default_return_url = 'circuits:provider_list'
class ProviderBulkImportView(PermissionRequiredMixin, BulkImportView):
@@ -60,21 +61,23 @@ class ProviderBulkImportView(PermissionRequiredMixin, BulkImportView):
form = forms.ProviderImportForm
table = tables.ProviderTable
template_name = 'circuits/provider_import.html'
obj_list_url = 'circuits:provider_list'
default_return_url = 'circuits:provider_list'
class ProviderBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'circuits.change_provider'
cls = Provider
filter = filters.ProviderFilter
form = forms.ProviderBulkEditForm
template_name = 'circuits/provider_bulk_edit.html'
default_redirect_url = 'circuits:provider_list'
default_return_url = 'circuits:provider_list'
class ProviderBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'circuits.delete_provider'
cls = Provider
default_redirect_url = 'circuits:provider_list'
filter = filters.ProviderFilter
default_return_url = 'circuits:provider_list'
#
@@ -84,7 +87,6 @@ class ProviderBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class CircuitTypeListView(ObjectListView):
queryset = CircuitType.objects.annotate(circuit_count=Count('circuits'))
table = tables.CircuitTypeTable
edit_permissions = ['circuits.change_circuittype', 'circuits.delete_circuittype']
template_name = 'circuits/circuittype_list.html'
@@ -92,14 +94,15 @@ class CircuitTypeEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'circuits.change_circuittype'
model = CircuitType
form_class = forms.CircuitTypeForm
obj_list_url = 'circuits:circuittype_list'
use_obj_view = False
def get_return_url(self, obj):
return reverse('circuits:circuittype_list')
class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'circuits.delete_circuittype'
cls = CircuitType
default_redirect_url = 'circuits:circuittype_list'
default_return_url = 'circuits:circuittype_list'
#
@@ -111,15 +114,22 @@ class CircuitListView(ObjectListView):
filter = filters.CircuitFilter
filter_form = forms.CircuitFilterForm
table = tables.CircuitTable
edit_permissions = ['circuits.change_circuit', 'circuits.delete_circuit']
template_name = 'circuits/circuit_list.html'
def circuit(request, pk):
circuit = get_object_or_404(Circuit, pk=pk)
termination_a = CircuitTermination.objects.filter(circuit=circuit, term_side=TERM_SIDE_A).first()
termination_z = CircuitTermination.objects.filter(circuit=circuit, term_side=TERM_SIDE_Z).first()
circuit = get_object_or_404(Circuit.objects.select_related('provider', 'type', 'tenant__group'), pk=pk)
termination_a = CircuitTermination.objects.select_related(
'site__region', 'interface__device'
).filter(
circuit=circuit, term_side=TERM_SIDE_A
).first()
termination_z = CircuitTermination.objects.select_related(
'site__region', 'interface__device'
).filter(
circuit=circuit, term_side=TERM_SIDE_Z
).first()
return render(request, 'circuits/circuit.html', {
'circuit': circuit,
@@ -134,13 +144,13 @@ class CircuitEditView(PermissionRequiredMixin, ObjectEditView):
form_class = forms.CircuitForm
fields_initial = ['provider']
template_name = 'circuits/circuit_edit.html'
obj_list_url = 'circuits:circuit_list'
default_return_url = 'circuits:circuit_list'
class CircuitDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'circuits.delete_circuit'
model = Circuit
redirect_url = 'circuits:circuit_list'
default_return_url = 'circuits:circuit_list'
class CircuitBulkImportView(PermissionRequiredMixin, BulkImportView):
@@ -148,21 +158,23 @@ class CircuitBulkImportView(PermissionRequiredMixin, BulkImportView):
form = forms.CircuitImportForm
table = tables.CircuitTable
template_name = 'circuits/circuit_import.html'
obj_list_url = 'circuits:circuit_list'
default_return_url = 'circuits:circuit_list'
class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'circuits.change_circuit'
cls = Circuit
filter = filters.CircuitFilter
form = forms.CircuitBulkEditForm
template_name = 'circuits/circuit_bulk_edit.html'
default_redirect_url = 'circuits:circuit_list'
default_return_url = 'circuits:circuit_list'
class CircuitBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'circuits.delete_circuit'
cls = Circuit
default_redirect_url = 'circuits:circuit_list'
filter = filters.CircuitFilter
default_return_url = 'circuits:circuit_list'
@permission_required('circuits.change_circuittermination')
@@ -206,7 +218,7 @@ def circuit_terminations_swap(request, pk):
'form': form,
'panel_class': 'default',
'button_class': 'primary',
'cancel_url': circuit.get_absolute_url(),
'return_url': circuit.get_absolute_url(),
})
@@ -221,12 +233,14 @@ class CircuitTerminationEditView(PermissionRequiredMixin, ObjectEditView):
fields_initial = ['term_side']
template_name = 'circuits/circuittermination_edit.html'
def alter_obj(self, obj, args, kwargs):
if 'circuit' in kwargs:
circuit = get_object_or_404(Circuit, pk=kwargs['circuit'])
obj.circuit = circuit
def alter_obj(self, obj, request, url_args, url_kwargs):
if 'circuit' in url_kwargs:
obj.circuit = get_object_or_404(Circuit, pk=url_kwargs['circuit'])
return obj
def get_return_url(self, obj):
return obj.circuit.get_absolute_url()
class CircuitTerminationDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'circuits.delete_circuittermination'

View File

@@ -1,13 +1,24 @@
from django.contrib import admin
from django.db.models import Count
from mptt.admin import MPTTModelAdmin
from .models import (
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, Manufacturer, Module, Platform,
PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackRole, Site,
PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, Region,
Site,
)
@admin.register(Region)
class RegionAdmin(MPTTModelAdmin):
list_display = ['name', 'parent', 'slug']
prepopulated_fields = {
'slug': ['name'],
}
@admin.register(Site)
class SiteAdmin(admin.ModelAdmin):
list_display = ['name', 'slug', 'facility', 'asn']
@@ -37,6 +48,11 @@ class RackAdmin(admin.ModelAdmin):
list_display = ['name', 'facility_id', 'site', 'group', 'tenant', 'role', 'type', 'width', 'u_height']
@admin.register(RackReservation)
class RackRackReservationAdmin(admin.ModelAdmin):
list_display = ['rack', 'units', 'description', 'user', 'created']
#
# Device types
#

View File

@@ -4,23 +4,42 @@ from ipam.models import IPAddress
from dcim.models import (
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceType,
DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet,
PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackRole, RACK_FACE_FRONT, RACK_FACE_REAR, Site,
SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT,
PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RACK_FACE_FRONT,
RACK_FACE_REAR, Region, Site, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT,
)
from extras.api.serializers import CustomFieldSerializer
from tenancy.api.serializers import TenantNestedSerializer
#
# Regions
#
class RegionNestedSerializer(serializers.ModelSerializer):
class Meta:
model = Region
fields = ['id', 'name', 'slug']
class RegionSerializer(serializers.ModelSerializer):
class Meta:
model = Region
fields = ['id', 'name', 'slug', 'parent']
#
# Sites
#
class SiteSerializer(CustomFieldSerializer, serializers.ModelSerializer):
region = RegionNestedSerializer()
tenant = TenantNestedSerializer()
class Meta:
model = Site
fields = ['id', 'name', 'slug', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address',
fields = ['id', 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address',
'contact_name', 'contact_phone', 'contact_email', 'comments', 'custom_fields', 'count_prefixes',
'count_vlans', 'count_racks', 'count_devices', 'count_circuits']
@@ -70,6 +89,12 @@ class RackRoleNestedSerializer(RackRoleSerializer):
# Racks
#
class RackReservationNestedSerializer(serializers.ModelSerializer):
class Meta:
model = RackReservation
fields = ['id', 'units', 'created', 'user', 'description']
class RackSerializer(CustomFieldSerializer, serializers.ModelSerializer):
site = SiteNestedSerializer()
@@ -92,10 +117,11 @@ class RackNestedSerializer(RackSerializer):
class RackDetailSerializer(RackSerializer):
front_units = serializers.SerializerMethodField()
rear_units = serializers.SerializerMethodField()
reservations = RackReservationNestedSerializer(many=True)
class Meta(RackSerializer.Meta):
fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width',
'u_height', 'desc_units', 'comments', 'custom_fields', 'front_units', 'rear_units']
'u_height', 'desc_units', 'reservations', 'comments', 'custom_fields', 'front_units', 'rear_units']
def get_front_units(self, obj):
units = obj.get_rack_units(face=RACK_FACE_FRONT)
@@ -110,6 +136,18 @@ class RackDetailSerializer(RackSerializer):
return units
#
# Rack reservations
#
class RackReservationSerializer(serializers.ModelSerializer):
rack = RackNestedSerializer()
class Meta:
model = RackReservation
fields = ['id', 'rack', 'units', 'created', 'user', 'description']
#
# Manufacturers
#
@@ -134,11 +172,13 @@ class ManufacturerNestedSerializer(ManufacturerSerializer):
class DeviceTypeSerializer(CustomFieldSerializer, serializers.ModelSerializer):
manufacturer = ManufacturerNestedSerializer()
subdevice_role = serializers.SerializerMethodField()
instance_count = serializers.IntegerField(source='instances.count', read_only=True)
class Meta:
model = DeviceType
fields = ['id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'comments', 'custom_fields']
'interface_ordering', 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role',
'comments', 'custom_fields', 'instance_count']
def get_subdevice_role(self, obj):
return {
@@ -198,9 +238,9 @@ class DeviceTypeDetailSerializer(DeviceTypeSerializer):
class Meta(DeviceTypeSerializer.Meta):
fields = ['id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'comments', 'custom_fields',
'console_port_templates', 'cs_port_templates', 'power_port_templates', 'power_outlet_templates',
'interface_templates']
'interface_ordering', 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role',
'comments', 'custom_fields', 'console_port_templates', 'cs_port_templates', 'power_port_templates',
'power_outlet_templates', 'interface_templates']
#
@@ -254,6 +294,7 @@ class DeviceSerializer(CustomFieldSerializer, serializers.ModelSerializer):
device_role = DeviceRoleNestedSerializer()
tenant = TenantNestedSerializer()
platform = PlatformNestedSerializer()
site = SiteNestedSerializer()
rack = RackNestedSerializer()
primary_ip = DeviceIPAddressNestedSerializer()
primary_ip4 = DeviceIPAddressNestedSerializer()
@@ -262,9 +303,11 @@ class DeviceSerializer(CustomFieldSerializer, serializers.ModelSerializer):
class Meta:
model = Device
fields = ['id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial',
'asset_tag', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4',
'primary_ip6', 'comments', 'custom_fields']
fields = [
'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6',
'comments', 'custom_fields',
]
def get_parent_device(self, obj):
try:
@@ -366,13 +409,24 @@ class PowerPortNestedSerializer(PowerPortSerializer):
# Interfaces
#
class InterfaceSerializer(serializers.ModelSerializer):
device = DeviceNestedSerializer()
class LAGInterfaceNestedSerializer(serializers.ModelSerializer):
form_factor = serializers.ReadOnlyField(source='get_form_factor_display')
class Meta:
model = Interface
fields = ['id', 'device', 'name', 'form_factor', 'mac_address', 'mgmt_only', 'description', 'is_connected']
fields = ['id', 'name', 'form_factor']
class InterfaceSerializer(serializers.ModelSerializer):
device = DeviceNestedSerializer()
form_factor = serializers.ReadOnlyField(source='get_form_factor_display')
lag = LAGInterfaceNestedSerializer()
class Meta:
model = Interface
fields = [
'id', 'device', 'name', 'form_factor', 'lag', 'mac_address', 'mgmt_only', 'description', 'is_connected',
]
class InterfaceNestedSerializer(InterfaceSerializer):
@@ -386,8 +440,10 @@ class InterfaceDetailSerializer(InterfaceSerializer):
connected_interface = InterfaceSerializer()
class Meta(InterfaceSerializer.Meta):
fields = ['id', 'device', 'name', 'form_factor', 'mac_address', 'mgmt_only', 'description', 'is_connected',
'connected_interface']
fields = [
'id', 'device', 'name', 'form_factor', 'lag', 'mac_address', 'mgmt_only', 'description', 'is_connected',
'connected_interface',
]
#

View File

@@ -8,6 +8,10 @@ from .views import *
urlpatterns = [
# Regions
url(r'^regions/$', RegionListView.as_view(), name='region_list'),
url(r'^regions/(?P<pk>\d+)/$', RegionDetailView.as_view(), name='region_detail'),
# Sites
url(r'^sites/$', SiteListView.as_view(), name='site_list'),
url(r'^sites/(?P<pk>\d+)/$', SiteDetailView.as_view(), name='site_detail'),
@@ -27,6 +31,10 @@ urlpatterns = [
url(r'^racks/(?P<pk>\d+)/$', RackDetailView.as_view(), name='rack_detail'),
url(r'^racks/(?P<pk>\d+)/rack-units/$', RackUnitListView.as_view(), name='rack_units'),
# Rack reservations
url(r'^rack-reservations/$', RackReservationListView.as_view(), name='rackreservation_list'),
url(r'^rack-reservations/(?P<pk>\d+)/$', RackReservationDetailView.as_view(), name='rackreservation_detail'),
# Manufacturers
url(r'^manufacturers/$', ManufacturerListView.as_view(), name='manufacturer_list'),
url(r'^manufacturers/(?P<pk>\d+)/$', ManufacturerDetailView.as_view(), name='manufacturer_detail'),

View File

@@ -10,8 +10,9 @@ from django.http import Http404
from django.shortcuts import get_object_or_404
from dcim.models import (
ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, DeviceType, IFACE_FF_VIRTUAL, Interface,
InterfaceConnection, Manufacturer, Module, Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackRole, Site,
ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, DeviceType, Interface, InterfaceConnection,
Manufacturer, Module, Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackReservation, RackRole, Region, Site,
VIRTUAL_IFACE_TYPES,
)
from dcim import filters
from extras.api.views import CustomFieldModelAPIView
@@ -21,6 +22,26 @@ from .exceptions import MissingFilterException
from . import serializers
#
# Regions
#
class RegionListView(generics.ListAPIView):
"""
List all regions
"""
queryset = Region.objects.all()
serializer_class = serializers.RegionSerializer
class RegionDetailView(generics.RetrieveAPIView):
"""
Retrieve a single region
"""
queryset = Region.objects.all()
serializer_class = serializers.RegionSerializer
#
# Sites
#
@@ -134,6 +155,27 @@ class RackUnitListView(APIView):
return Response(elevation)
#
# Rack reservations
#
class RackReservationListView(generics.ListAPIView):
"""
List all rack reservation
"""
queryset = RackReservation.objects.all()
serializer_class = serializers.RackReservationSerializer
filter_class = filters.RackReservationFilter
class RackReservationDetailView(generics.RetrieveAPIView):
"""
Retrieve a single rack reservation
"""
queryset = RackReservation.objects.all()
serializer_class = serializers.RackReservationSerializer
#
# Manufacturers
#
@@ -331,14 +373,15 @@ class InterfaceListView(generics.ListAPIView):
def get_queryset(self):
device = get_object_or_404(Device, pk=self.kwargs['pk'])
queryset = Interface.objects.filter(device=device).select_related('connected_as_a', 'connected_as_b')
queryset = Interface.objects.order_naturally(device.device_type.interface_ordering).filter(device=device)\
.select_related('connected_as_a', 'connected_as_b', 'circuit_termination')
# 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)
queryset = queryset.exclude(form_factor__in=VIRTUAL_IFACE_TYPES)
elif iface_type == 'virtual':
queryset = queryset.filter(form_factor=IFACE_FF_VIRTUAL)
queryset = queryset.filter(form_factor__in=VIRTUAL_IFACE_TYPES)
elif iface_type is not None:
queryset = queryset.empty()
@@ -489,8 +532,8 @@ class RelatedConnectionsView(APIView):
response['power-ports'].append(data)
# Interface connections
interfaces = Interface.objects.filter(device=device).select_related('connected_as_a', 'connected_as_b',
'circuit_termination')
interfaces = Interface.objects.order_naturally(device.device_type.interface_ordering).filter(device=device)\
.select_related('connected_as_a', 'connected_as_b', 'circuit_termination')
for iface in interfaces:
data = serializers.InterfaceDetailSerializer(instance=iface).data
del(data['device'])

View File

@@ -7,16 +7,28 @@ from extras.filters import CustomFieldFilterSet
from tenancy.models import Tenant
from utilities.filters import NullableModelMultipleChoiceFilter
from .models import (
ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, Interface, InterfaceConnection, Manufacturer,
Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackRole, Site,
ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, IFACE_FF_LAG, Interface, InterfaceConnection,
Manufacturer, Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackReservation, RackRole, Region, Site,
VIRTUAL_IFACE_TYPES,
)
class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
q = django_filters.CharFilter(
method='search',
label='Search',
)
region_id = NullableModelMultipleChoiceFilter(
name='region',
queryset=Region.objects.all(),
label='Region (ID)',
)
region = NullableModelMultipleChoiceFilter(
name='region',
queryset=Region.objects.all(),
to_field_name='slug',
label='Region (slug)',
)
tenant_id = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
@@ -33,9 +45,16 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
model = Site
fields = ['q', 'name', 'facility', 'asn']
def search(self, queryset, value):
qs_filter = Q(name__icontains=value) | Q(facility__icontains=value) | Q(physical_address__icontains=value) | \
Q(shipping_address__icontains=value) | Q(comments__icontains=value)
def search(self, queryset, name, value):
if not value.strip():
return queryset
qs_filter = (
Q(name__icontains=value) |
Q(facility__icontains=value) |
Q(physical_address__icontains=value) |
Q(shipping_address__icontains=value) |
Q(comments__icontains=value)
)
try:
qs_filter |= Q(asn=int(value.strip()))
except ValueError:
@@ -58,11 +77,12 @@ class RackGroupFilter(django_filters.FilterSet):
class Meta:
model = RackGroup
fields = ['name']
class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
q = django_filters.CharFilter(
method='search',
label='Search',
)
site_id = django_filters.ModelMultipleChoiceFilter(
@@ -114,7 +134,9 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
model = Rack
fields = ['u_height']
def search(self, queryset, value):
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(facility_id__icontains=value) |
@@ -122,9 +144,21 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
)
class RackReservationFilter(django_filters.FilterSet):
rack_id = django_filters.ModelMultipleChoiceFilter(
name='rack',
queryset=Rack.objects.all(),
label='Rack (ID)',
)
class Meta:
model = RackReservation
fields = ['rack', 'user']
class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
q = django_filters.CharFilter(
method='search',
label='Search',
)
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
@@ -141,10 +175,13 @@ class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet):
class Meta:
model = DeviceType
fields = ['model', 'part_number', 'u_height', 'is_console_server', 'is_pdu', 'is_network_device',
'subdevice_role']
fields = [
'model', 'part_number', 'u_height', 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role',
]
def search(self, queryset, value):
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(manufacturer__name__icontains=value) |
Q(model__icontains=value) |
@@ -154,21 +191,21 @@ class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet):
class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
q = django_filters.CharFilter(
method='search',
label='Search',
)
mac_address = django_filters.MethodFilter(
action='_mac_address',
mac_address = django_filters.CharFilter(
method='_mac_address',
label='MAC address',
)
site_id = django_filters.ModelMultipleChoiceFilter(
name='rack__site',
name='site',
queryset=Site.objects.all(),
label='Site (ID)',
)
site = django_filters.ModelMultipleChoiceFilter(
name='rack__site__slug',
name='site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site name (slug)',
@@ -178,7 +215,7 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
queryset=RackGroup.objects.all(),
label='Rack group (ID)',
)
rack_id = django_filters.ModelMultipleChoiceFilter(
rack_id = NullableModelMultipleChoiceFilter(
name='rack',
queryset=Rack.objects.all(),
label='Rack (ID)',
@@ -259,7 +296,9 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
model = Device
fields = ['name', 'serial', 'asset_tag']
def search(self, queryset, value):
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(serial__icontains=value.strip()) |
@@ -268,7 +307,7 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
Q(comments__icontains=value)
).distinct()
def _mac_address(self, queryset, value):
def _mac_address(self, queryset, name, value):
value = value.strip()
if not value:
return queryset
@@ -362,58 +401,72 @@ class InterfaceFilter(django_filters.FilterSet):
to_field_name='name',
label='Device (name)',
)
type = django_filters.CharFilter(
method='filter_type',
label='Interface type',
)
class Meta:
model = Interface
fields = ['name']
def filter_type(self, queryset, name, value):
value = value.strip().lower()
if value == 'physical':
return queryset.exclude(form_factor__in=VIRTUAL_IFACE_TYPES)
elif value == 'virtual':
return queryset.filter(form_factor__in=VIRTUAL_IFACE_TYPES)
elif value == 'lag':
return queryset.filter(form_factor=IFACE_FF_LAG)
return queryset
class ConsoleConnectionFilter(django_filters.FilterSet):
site = django_filters.MethodFilter(
action='filter_site',
site = django_filters.CharFilter(
method='filter_site',
label='Site (slug)',
)
class Meta:
model = ConsoleServerPort
fields = []
def filter_site(self, queryset, value):
value = value.strip()
if not value:
def filter_site(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(cs_port__device__rack__site__slug=value)
return queryset.filter(cs_port__device__site__slug=value)
class PowerConnectionFilter(django_filters.FilterSet):
site = django_filters.MethodFilter(
action='filter_site',
site = django_filters.CharFilter(
method='filter_site',
label='Site (slug)',
)
class Meta:
model = PowerOutlet
fields = []
def filter_site(self, queryset, value):
value = value.strip()
if not value:
def filter_site(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(power_outlet__device__rack__site__slug=value)
return queryset.filter(power_outlet__device__site__slug=value)
class InterfaceConnectionFilter(django_filters.FilterSet):
site = django_filters.MethodFilter(
action='filter_site',
site = django_filters.CharFilter(
method='filter_site',
label='Site (slug)',
)
class Meta:
model = InterfaceConnection
fields = []
def filter_site(self, queryset, value):
value = value.strip()
if not value:
def filter_site(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(interface_a__device__rack__site__slug=value) |
Q(interface_b__device__rack__site__slug=value)
Q(interface_a__device__site__slug=value) |
Q(interface_b__device__site__slug=value)
)

View File

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

View File

@@ -1,6 +1,9 @@
import re
from mptt.forms import TreeNodeChoiceField
from django import forms
from django.contrib.postgres.forms.array import SimpleArrayField
from django.core.exceptions import ValidationError
from django.db.models import Count, Q
@@ -8,18 +11,19 @@ from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFi
from ipam.models import IPAddress
from tenancy.models import Tenant
from utilities.forms import (
APISelect, add_blank_choice, BootstrapMixin, BulkEditForm, BulkImportForm, CommentField, CSVDataField,
ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea,
SlugField,
APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkImportForm, CommentField,
CSVDataField, ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled,
SmallTextarea, SlugField, FilterTreeNodeMultipleChoiceField,
)
from formfields import MACAddressFormField
from .formfields import MACAddressFormField
from .models import (
DeviceBay, DeviceBayTemplate, CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_PLANNED, CONNECTION_STATUS_CONNECTED,
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType,
Interface, IFACE_FF_CHOICES, IFACE_FF_VIRTUAL, InterfaceConnection, InterfaceTemplate, Manufacturer, Module,
Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES,
Rack, RackGroup, RackRole, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD
Interface, IFACE_FF_CHOICES, IFACE_FF_LAG, IFACE_ORDERING_CHOICES, InterfaceConnection, InterfaceTemplate,
Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_TYPE_CHOICES,
RACK_WIDTH_CHOICES, Rack, RackGroup, RackReservation, RackRole, Region, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD,
VIRTUAL_IFACE_TYPES
)
@@ -52,18 +56,42 @@ def validate_connection_status(value):
raise ValidationError('Invalid connection status ({}); must be either "planned" or "connected".'.format(value))
class DeviceComponentForm(BootstrapMixin, forms.Form):
"""
Allow inclusion of the parent device as context for limiting field choices.
"""
def __init__(self, device, *args, **kwargs):
self.device = device
super(DeviceComponentForm, self).__init__(*args, **kwargs)
#
# Regions
#
class RegionForm(BootstrapMixin, forms.ModelForm):
slug = SlugField()
class Meta:
model = Region
fields = ['parent', 'name', 'slug']
#
# Sites
#
class SiteForm(BootstrapMixin, CustomFieldForm):
region = TreeNodeChoiceField(queryset=Region.objects.all(), required=False)
slug = SlugField()
comments = CommentField()
class Meta:
model = Site
fields = ['name', 'slug', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', 'contact_name',
'contact_phone', 'contact_email', 'comments']
fields = [
'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address',
'contact_name', 'contact_phone', 'contact_email', 'comments',
]
widgets = {
'physical_address': SmallTextarea(attrs={'rows': 3}),
'shipping_address': SmallTextarea(attrs={'rows': 3}),
@@ -78,12 +106,22 @@ class SiteForm(BootstrapMixin, CustomFieldForm):
class SiteFromCSVForm(forms.ModelForm):
tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
error_messages={'invalid_choice': 'Tenant not found.'})
region = forms.ModelChoiceField(
Region.objects.all(), to_field_name='name', required=False, error_messages={
'invalid_choice': 'Tenant not found.'
}
)
tenant = forms.ModelChoiceField(
Tenant.objects.all(), to_field_name='name', required=False, error_messages={
'invalid_choice': 'Tenant not found.'
}
)
class Meta:
model = Site
fields = ['name', 'slug', 'tenant', 'facility', 'asn', 'contact_name', 'contact_phone', 'contact_email']
fields = [
'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'contact_name', 'contact_phone', 'contact_email',
]
class SiteImportForm(BootstrapMixin, BulkImportForm):
@@ -92,17 +130,27 @@ class SiteImportForm(BootstrapMixin, BulkImportForm):
class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Site.objects.all(), widget=forms.MultipleHiddenInput)
region = TreeNodeChoiceField(queryset=Region.objects.all(), required=False)
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
asn = forms.IntegerField(min_value=1, max_value=4294967295, required=False, label='ASN')
class Meta:
nullable_fields = ['tenant', 'asn']
nullable_fields = ['region', 'tenant', 'asn']
class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Site
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('sites')), to_field_name='slug',
null_option=(0, 'None'))
q = forms.CharField(required=False, label='Search')
region = FilterTreeNodeMultipleChoiceField(
queryset=Region.objects.annotate(filter_count=Count('sites')),
to_field_name='slug',
required=False,
)
tenant = FilterChoiceField(
queryset=Tenant.objects.annotate(filter_count=Count('sites')),
to_field_name='slug',
null_option=(0, 'None')
)
#
@@ -232,13 +280,54 @@ class RackBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
class RackFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Rack
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('racks')), to_field_name='slug')
group_id = FilterChoiceField(queryset=RackGroup.objects.select_related('site')
.annotate(filter_count=Count('racks')), label='Rack group', null_option=(0, 'None'))
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('racks')), to_field_name='slug',
null_option=(0, 'None'))
role = FilterChoiceField(queryset=RackRole.objects.annotate(filter_count=Count('racks')), to_field_name='slug',
null_option=(0, 'None'))
q = forms.CharField(required=False, label='Search')
site = FilterChoiceField(
queryset=Site.objects.annotate(filter_count=Count('racks')),
to_field_name='slug'
)
group_id = FilterChoiceField(
queryset=RackGroup.objects.select_related('site').annotate(filter_count=Count('racks')),
label='Rack group',
null_option=(0, 'None')
)
tenant = FilterChoiceField(
queryset=Tenant.objects.annotate(filter_count=Count('racks')),
to_field_name='slug',
null_option=(0, 'None')
)
role = FilterChoiceField(
queryset=RackRole.objects.annotate(filter_count=Count('racks')),
to_field_name='slug',
null_option=(0, 'None')
)
#
# Rack reservations
#
class RackReservationForm(BootstrapMixin, forms.ModelForm):
units = SimpleArrayField(forms.IntegerField(), widget=ArrayFieldSelectMultiple(attrs={'size': 10}))
class Meta:
model = RackReservation
fields = ['units', 'description']
def __init__(self, *args, **kwargs):
super(RackReservationForm, self).__init__(*args, **kwargs)
# Populate rack unit choices
self.fields['units'].widget.choices = self._get_unit_choices()
def _get_unit_choices(self):
rack = self.instance.rack
reserved_units = []
for resv in rack.reservations.exclude(pk=self.instance.pk):
for u in resv.units:
reserved_units.append(u)
unit_choices = [(u, {'label': str(u), 'disabled': u in reserved_units}) for u in rack.units]
return unit_choices
#
@@ -263,13 +352,17 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldForm):
class Meta:
model = DeviceType
fields = ['manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server',
'is_pdu', 'is_network_device', 'subdevice_role', 'comments']
'is_pdu', 'is_network_device', 'subdevice_role', 'interface_ordering', 'comments']
labels = {
'interface_ordering': 'Order interfaces by',
}
class DeviceTypeBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=DeviceType.objects.all(), widget=forms.MultipleHiddenInput)
manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(), required=False)
u_height = forms.IntegerField(min_value=1, required=False)
interface_ordering = forms.ChoiceField(choices=add_blank_choice(IFACE_ORDERING_CHOICES), required=False)
class Meta:
nullable_fields = []
@@ -277,8 +370,11 @@ class DeviceTypeBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
class DeviceTypeFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = DeviceType
manufacturer = FilterChoiceField(queryset=Manufacturer.objects.annotate(filter_count=Count('device_types')),
to_field_name='slug')
q = forms.CharField(required=False, label='Search')
manufacturer = FilterChoiceField(
queryset=Manufacturer.objects.annotate(filter_count=Count('device_types')),
to_field_name='slug'
)
#
@@ -295,7 +391,7 @@ class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm):
}
class ConsolePortTemplateCreateForm(BootstrapMixin, forms.Form):
class ConsolePortTemplateCreateForm(DeviceComponentForm):
name_pattern = ExpandableNameField(label='Name')
@@ -309,7 +405,7 @@ class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm):
}
class ConsoleServerPortTemplateCreateForm(BootstrapMixin, forms.Form):
class ConsoleServerPortTemplateCreateForm(DeviceComponentForm):
name_pattern = ExpandableNameField(label='Name')
@@ -323,7 +419,7 @@ class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm):
}
class PowerPortTemplateCreateForm(BootstrapMixin, forms.Form):
class PowerPortTemplateCreateForm(DeviceComponentForm):
name_pattern = ExpandableNameField(label='Name')
@@ -337,7 +433,7 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
}
class PowerOutletTemplateCreateForm(BootstrapMixin, forms.Form):
class PowerOutletTemplateCreateForm(DeviceComponentForm):
name_pattern = ExpandableNameField(label='Name')
@@ -351,7 +447,7 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):
}
class InterfaceTemplateCreateForm(BootstrapMixin, forms.Form):
class InterfaceTemplateCreateForm(DeviceComponentForm):
name_pattern = ExpandableNameField(label='Name')
form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES)
mgmt_only = forms.BooleanField(required=False, label='OOB Management')
@@ -375,7 +471,7 @@ class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm):
}
class DeviceBayTemplateCreateForm(BootstrapMixin, forms.Form):
class DeviceBayTemplateCreateForm(DeviceComponentForm):
name_pattern = ExpandableNameField(label='Name')
@@ -409,7 +505,7 @@ class PlatformForm(BootstrapMixin, forms.ModelForm):
class DeviceForm(BootstrapMixin, CustomFieldForm):
site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.Select(attrs={'filter-for': 'rack'}))
rack = forms.ModelChoiceField(queryset=Rack.objects.all(), widget=APISelect(
rack = forms.ModelChoiceField(queryset=Rack.objects.all(), required=False, widget=APISelect(
api_url='/api/dcim/racks/?site_id={{site}}',
display_field='display_name',
attrs={'filter-for': 'position'}
@@ -446,7 +542,7 @@ class DeviceForm(BootstrapMixin, CustomFieldForm):
if self.instance.pk:
# Initialize helper selections
self.initial['site'] = self.instance.rack.site
self.initial['site'] = self.instance.site
self.initial['manufacturer'] = self.instance.device_type.manufacturer
# Compile list of choices for primary IPv4 and IPv6 addresses
@@ -513,7 +609,7 @@ class DeviceForm(BootstrapMixin, CustomFieldForm):
if pk and self.instance.device_type.is_child_device and hasattr(self.instance, 'parent_bay'):
self.fields['site'].disabled = True
self.fields['rack'].disabled = True
self.initial['site'] = self.instance.parent_bay.device.rack.site_id
self.initial['site'] = self.instance.parent_bay.device.site_id
self.initial['rack'] = self.instance.parent_bay.device.rack_id
@@ -549,7 +645,7 @@ class DeviceFromCSVForm(BaseDeviceFromCSVForm):
site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name', error_messages={
'invalid_choice': 'Invalid site name.',
})
rack_name = forms.CharField()
rack_name = forms.CharField(required=False)
face = forms.CharField(required=False)
class Meta(BaseDeviceFromCSVForm.Meta):
@@ -635,18 +731,46 @@ class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Device
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('racks__devices')), to_field_name='slug')
rack_group_id = FilterChoiceField(queryset=RackGroup.objects.annotate(filter_count=Count('racks__devices')),
label='Rack Group')
role = FilterChoiceField(queryset=DeviceRole.objects.annotate(filter_count=Count('devices')), to_field_name='slug')
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('devices')), to_field_name='slug',
null_option=(0, 'None'))
device_type_id = FilterChoiceField(queryset=DeviceType.objects.select_related('manufacturer')
.annotate(filter_count=Count('instances')), label='Type')
platform = FilterChoiceField(queryset=Platform.objects.annotate(filter_count=Count('devices')),
to_field_name='slug', null_option=(0, 'None'))
status = forms.NullBooleanField(required=False, widget=forms.Select(choices=FORM_STATUS_CHOICES))
mac_address = forms.CharField(required=False, label='MAC address')
q = forms.CharField(required=False, label='Search')
site = FilterChoiceField(
queryset=Site.objects.annotate(filter_count=Count('racks__devices')),
to_field_name='slug',
)
rack_group_id = FilterChoiceField(
queryset=RackGroup.objects.select_related('site').annotate(filter_count=Count('racks__devices')),
label='Rack group',
)
role = FilterChoiceField(
queryset=DeviceRole.objects.annotate(filter_count=Count('devices')),
to_field_name='slug',
)
tenant = FilterChoiceField(
queryset=Tenant.objects.annotate(filter_count=Count('devices')), to_field_name='slug',
null_option=(0, 'None'),
)
manufacturer_id = FilterChoiceField(
queryset=Manufacturer.objects.all(),
label='Manufacturer',
)
device_type_id = FilterChoiceField(
queryset=DeviceType.objects.select_related('manufacturer').order_by('model').annotate(
filter_count=Count('instances'),
),
label='Model',
)
platform = FilterChoiceField(
queryset=Platform.objects.annotate(filter_count=Count('devices')),
to_field_name='slug',
null_option=(0, 'None'),
)
status = forms.NullBooleanField(
required=False,
widget=forms.Select(choices=FORM_STATUS_CHOICES),
)
mac_address = forms.CharField(
required=False,
label='MAC address',
)
#
@@ -679,14 +803,18 @@ class ConsolePortForm(BootstrapMixin, forms.ModelForm):
}
class ConsolePortCreateForm(BootstrapMixin, forms.Form):
class ConsolePortCreateForm(DeviceComponentForm):
name_pattern = ExpandableNameField(label='Name')
class ConsoleConnectionCSVForm(forms.Form):
console_server = FlexibleModelChoiceField(queryset=Device.objects.filter(device_type__is_console_server=True),
to_field_name='name',
error_messages={'invalid_choice': 'Console server not found'})
console_server = FlexibleModelChoiceField(
queryset=Device.objects.filter(device_type__is_console_server=True),
to_field_name='name',
error_messages={
'invalid_choice': 'Console server not found',
}
)
cs_port = forms.CharField()
device = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Device not found'})
@@ -751,22 +879,49 @@ class ConsoleConnectionImportForm(BootstrapMixin, BulkImportForm):
class ConsolePortConnectionForm(BootstrapMixin, forms.ModelForm):
rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False,
widget=forms.Select(attrs={'filter-for': 'console_server'}))
console_server = forms.ModelChoiceField(queryset=Device.objects.all(), label='Console Server', required=False,
widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}&is_console_server=True',
display_field='display_name',
attrs={'filter-for': 'cs_port'}))
livesearch = forms.CharField(required=False, label='Console Server', widget=Livesearch(
query_key='q', query_url='dcim-api:device_list', field_to_update='console_server')
site = forms.ModelChoiceField(
queryset=Site.objects.all(),
widget=forms.HiddenInput(),
)
rack = forms.ModelChoiceField(
queryset=Rack.objects.all(),
label='Rack',
required=False,
widget=forms.Select(
attrs={'filter-for': 'console_server', 'nullable': 'true'}
)
)
console_server = forms.ModelChoiceField(
queryset=Device.objects.all(),
label='Console Server',
required=False,
widget=APISelect(
api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}&is_console_server=True',
display_field='display_name',
attrs={'filter-for': 'cs_port'}
)
)
livesearch = forms.CharField(
required=False,
label='Console Server',
widget=Livesearch(
query_key='q',
query_url='dcim-api:device_list',
field_to_update='console_server',
)
)
cs_port = forms.ModelChoiceField(
queryset=ConsoleServerPort.objects.all(),
label='Port',
widget=APISelect(
api_url='/api/dcim/devices/{{console_server}}/console-server-ports/',
disabled_indicator='connected_console',
)
)
cs_port = forms.ModelChoiceField(queryset=ConsoleServerPort.objects.all(), label='Port',
widget=APISelect(api_url='/api/dcim/devices/{{console_server}}/console-server-ports/',
disabled_indicator='connected_console'))
class Meta:
model = ConsolePort
fields = ['rack', 'console_server', 'livesearch', 'cs_port', 'connection_status']
fields = ['site', 'rack', 'console_server', 'livesearch', 'cs_port', 'connection_status']
labels = {
'cs_port': 'Port',
'connection_status': 'Status',
@@ -779,17 +934,22 @@ class ConsolePortConnectionForm(BootstrapMixin, forms.ModelForm):
if not self.instance.pk:
raise RuntimeError("ConsolePortConnectionForm must be initialized with an existing ConsolePort instance.")
self.fields['rack'].queryset = Rack.objects.filter(site=self.instance.device.rack.site)
self.initial['site'] = self.instance.device.site
self.fields['rack'].queryset = Rack.objects.filter(site=self.instance.device.site)
self.fields['cs_port'].required = True
self.fields['connection_status'].choices = CONNECTION_STATUS_CHOICES
# Initialize console server choices
if self.is_bound and self.data.get('rack'):
self.fields['console_server'].queryset = Device.objects.filter(rack=self.data['rack'], device_type__is_console_server=True)
self.fields['console_server'].queryset = Device.objects.filter(rack=self.data['rack'],
device_type__is_console_server=True)
elif self.initial.get('rack'):
self.fields['console_server'].queryset = Device.objects.filter(rack=self.initial['rack'], device_type__is_console_server=True)
self.fields['console_server'].queryset = Device.objects.filter(rack=self.initial['rack'],
device_type__is_console_server=True)
else:
self.fields['console_server'].choices = []
self.fields['console_server'].queryset = Device.objects.filter(site=self.instance.device.site,
rack__isnull=True,
device_type__is_console_server=True)
# Initialize CS port choices
if self.is_bound:
@@ -814,27 +974,61 @@ class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm):
}
class ConsoleServerPortCreateForm(BootstrapMixin, forms.Form):
class ConsoleServerPortCreateForm(DeviceComponentForm):
name_pattern = ExpandableNameField(label='Name')
class ConsoleServerPortConnectionForm(BootstrapMixin, forms.Form):
rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False,
widget=forms.Select(attrs={'filter-for': 'device'}))
device = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False,
widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}',
display_field='display_name', attrs={'filter-for': 'port'}))
livesearch = forms.CharField(required=False, label='Device', widget=Livesearch(
query_key='q', query_url='dcim-api:device_list', field_to_update='device')
site = forms.ModelChoiceField(
queryset=Site.objects.all(),
widget=forms.HiddenInput(),
)
rack = forms.ModelChoiceField(
queryset=Rack.objects.all(),
label='Rack',
required=False,
widget=forms.Select(
attrs={'filter-for': 'device', 'nullable': 'true'}
)
)
device = forms.ModelChoiceField(
queryset=Device.objects.all(),
label='Device',
required=False,
widget=APISelect(
api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}',
display_field='display_name',
attrs={'filter-for': 'port'}
)
)
livesearch = forms.CharField(
required=False,
label='Device',
widget=Livesearch(
query_key='q',
query_url='dcim-api:device_list',
field_to_update='device'
)
)
port = forms.ModelChoiceField(
queryset=ConsolePort.objects.all(),
label='Port',
widget=APISelect(
api_url='/api/dcim/devices/{{device}}/console-ports/',
disabled_indicator='cs_port'
)
)
connection_status = forms.BooleanField(
required=False,
initial=CONNECTION_STATUS_CONNECTED,
label='Status',
widget=forms.Select(
choices=CONNECTION_STATUS_CHOICES
)
)
port = forms.ModelChoiceField(queryset=ConsolePort.objects.all(), label='Port',
widget=APISelect(api_url='/api/dcim/devices/{{device}}/console-ports/',
disabled_indicator='cs_port'))
connection_status = forms.BooleanField(required=False, initial=CONNECTION_STATUS_CONNECTED, label='Status',
widget=forms.Select(choices=CONNECTION_STATUS_CHOICES))
class Meta:
fields = ['rack', 'device', 'livesearch', 'port', 'connection_status']
fields = ['site', 'rack', 'device', 'livesearch', 'port', 'connection_status']
labels = {
'connection_status': 'Status',
}
@@ -843,7 +1037,8 @@ class ConsoleServerPortConnectionForm(BootstrapMixin, forms.Form):
super(ConsoleServerPortConnectionForm, self).__init__(*args, **kwargs)
self.fields['rack'].queryset = Rack.objects.filter(site=consoleserverport.device.rack.site)
self.initial['site'] = consoleserverport.device.site
self.fields['rack'].queryset = Rack.objects.filter(site=consoleserverport.device.site)
# Initialize device choices
if self.is_bound and self.data.get('rack'):
@@ -851,7 +1046,8 @@ class ConsoleServerPortConnectionForm(BootstrapMixin, forms.Form):
elif self.initial.get('rack', None):
self.fields['device'].queryset = Device.objects.filter(rack=self.initial['rack'])
else:
self.fields['device'].choices = []
self.fields['device'].queryset = Device.objects.filter(site=consoleserverport.device.site,
rack__isnull=True)
# Initialize port choices
if self.is_bound:
@@ -876,13 +1072,18 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm):
}
class PowerPortCreateForm(BootstrapMixin, forms.Form):
class PowerPortCreateForm(DeviceComponentForm):
name_pattern = ExpandableNameField(label='Name')
class PowerConnectionCSVForm(forms.Form):
pdu = FlexibleModelChoiceField(queryset=Device.objects.filter(device_type__is_pdu=True), to_field_name='name',
error_messages={'invalid_choice': 'PDU not found.'})
pdu = FlexibleModelChoiceField(
queryset=Device.objects.filter(device_type__is_pdu=True),
to_field_name='name',
error_messages={
'invalid_choice': 'PDU not found.',
}
)
power_outlet = forms.CharField()
device = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Device not found'})
@@ -948,21 +1149,46 @@ class PowerConnectionImportForm(BootstrapMixin, BulkImportForm):
class PowerPortConnectionForm(BootstrapMixin, forms.ModelForm):
rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False,
widget=forms.Select(attrs={'filter-for': 'pdu'}))
pdu = forms.ModelChoiceField(queryset=Device.objects.all(), label='PDU', required=False,
widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}&is_pdu=True',
display_field='display_name', attrs={'filter-for': 'power_outlet'}))
livesearch = forms.CharField(required=False, label='PDU', widget=Livesearch(
query_key='q', query_url='dcim-api:device_list', field_to_update='pdu')
site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.HiddenInput())
rack = forms.ModelChoiceField(
queryset=Rack.objects.all(),
label='Rack',
required=False,
widget=forms.Select(
attrs={'filter-for': 'pdu', 'nullable': 'true'}
)
)
pdu = forms.ModelChoiceField(
queryset=Device.objects.all(),
label='PDU',
required=False,
widget=APISelect(
api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}&is_pdu=True',
display_field='display_name',
attrs={'filter-for': 'power_outlet'}
)
)
livesearch = forms.CharField(
required=False,
label='PDU',
widget=Livesearch(
query_key='q',
query_url='dcim-api:device_list',
field_to_update='pdu'
)
)
power_outlet = forms.ModelChoiceField(
queryset=PowerOutlet.objects.all(),
label='Outlet',
widget=APISelect(
api_url='/api/dcim/devices/{{pdu}}/power-outlets/',
disabled_indicator='connected_port'
)
)
power_outlet = forms.ModelChoiceField(queryset=PowerOutlet.objects.all(), label='Outlet',
widget=APISelect(api_url='/api/dcim/devices/{{pdu}}/power-outlets/',
disabled_indicator='connected_port'))
class Meta:
model = PowerPort
fields = ['rack', 'pdu', 'livesearch', 'power_outlet', 'connection_status']
fields = ['site', 'rack', 'pdu', 'livesearch', 'power_outlet', 'connection_status']
labels = {
'power_outlet': 'Outlet',
'connection_status': 'Status',
@@ -975,17 +1201,22 @@ class PowerPortConnectionForm(BootstrapMixin, forms.ModelForm):
if not self.instance.pk:
raise RuntimeError("PowerPortConnectionForm must be initialized with an existing PowerPort instance.")
self.fields['rack'].queryset = Rack.objects.filter(site=self.instance.device.rack.site)
self.initial['site'] = self.instance.device.site
self.fields['rack'].queryset = Rack.objects.filter(site=self.instance.device.site)
self.fields['power_outlet'].required = True
self.fields['connection_status'].choices = CONNECTION_STATUS_CHOICES
# Initialize PDU choices
if self.is_bound and self.data.get('rack'):
self.fields['pdu'].queryset = Device.objects.filter(rack=self.data['rack'], device_type__is_pdu=True)
self.fields['pdu'].queryset = Device.objects.filter(rack=self.data['rack'],
device_type__is_pdu=True)
elif self.initial.get('rack', None):
self.fields['pdu'].queryset = Device.objects.filter(rack=self.initial['rack'], device_type__is_pdu=True)
self.fields['pdu'].queryset = Device.objects.filter(rack=self.initial['rack'],
device_type__is_pdu=True)
else:
self.fields['pdu'].choices = []
self.fields['pdu'].queryset = Device.objects.filter(site=self.instance.device.site,
rack__isnull=True,
device_type__is_pdu=True)
# Initialize power outlet choices
if self.is_bound:
@@ -1010,27 +1241,61 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm):
}
class PowerOutletCreateForm(BootstrapMixin, forms.Form):
class PowerOutletCreateForm(DeviceComponentForm):
name_pattern = ExpandableNameField(label='Name')
class PowerOutletConnectionForm(BootstrapMixin, forms.Form):
rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False,
widget=forms.Select(attrs={'filter-for': 'device'}))
device = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False,
widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}',
display_field='display_name', attrs={'filter-for': 'port'}))
livesearch = forms.CharField(required=False, label='Device', widget=Livesearch(
query_key='q', query_url='dcim-api:device_list', field_to_update='device')
site = forms.ModelChoiceField(
queryset=Site.objects.all(),
widget=forms.HiddenInput()
)
rack = forms.ModelChoiceField(
queryset=Rack.objects.all(),
label='Rack',
required=False,
widget=forms.Select(
attrs={'filter-for': 'device', 'nullable': 'true'}
)
)
device = forms.ModelChoiceField(
queryset=Device.objects.all(),
label='Device',
required=False,
widget=APISelect(
api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}',
display_field='display_name',
attrs={'filter-for': 'port'}
)
)
livesearch = forms.CharField(
required=False,
label='Device',
widget=Livesearch(
query_key='q',
query_url='dcim-api:device_list',
field_to_update='device'
)
)
port = forms.ModelChoiceField(
queryset=PowerPort.objects.all(),
label='Port',
widget=APISelect(
api_url='/api/dcim/devices/{{device}}/power-ports/',
disabled_indicator='power_outlet'
)
)
connection_status = forms.BooleanField(
required=False,
initial=CONNECTION_STATUS_CONNECTED,
label='Status',
widget=forms.Select(
choices=CONNECTION_STATUS_CHOICES
)
)
port = forms.ModelChoiceField(queryset=PowerPort.objects.all(), label='Port',
widget=APISelect(api_url='/api/dcim/devices/{{device}}/power-ports/',
disabled_indicator='power_outlet'))
connection_status = forms.BooleanField(required=False, initial=CONNECTION_STATUS_CONNECTED, label='Status',
widget=forms.Select(choices=CONNECTION_STATUS_CHOICES))
class Meta:
fields = ['rack', 'device', 'livesearch', 'port', 'connection_status']
fields = ['site', 'rack', 'device', 'livesearch', 'port', 'connection_status']
labels = {
'connection_status': 'Status',
}
@@ -1039,7 +1304,8 @@ class PowerOutletConnectionForm(BootstrapMixin, forms.Form):
super(PowerOutletConnectionForm, self).__init__(*args, **kwargs)
self.fields['rack'].queryset = Rack.objects.filter(site=poweroutlet.device.rack.site)
self.initial['site'] = poweroutlet.device.site
self.fields['rack'].queryset = Rack.objects.filter(site=poweroutlet.device.site)
# Initialize device choices
if self.is_bound and self.data.get('rack'):
@@ -1047,7 +1313,8 @@ class PowerOutletConnectionForm(BootstrapMixin, forms.Form):
elif self.initial.get('rack', None):
self.fields['device'].queryset = Device.objects.filter(rack=self.initial['rack'])
else:
self.fields['device'].choices = []
self.fields['device'].queryset = Device.objects.filter(site=poweroutlet.device.site,
rack__isnull=True)
# Initialize port choices
if self.is_bound:
@@ -1066,27 +1333,65 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = Interface
fields = ['device', 'name', 'form_factor', 'mac_address', 'mgmt_only', 'description']
fields = ['device', 'name', 'form_factor', 'lag', 'mac_address', 'mgmt_only', 'description']
widgets = {
'device': forms.HiddenInput(),
}
def __init__(self, *args, **kwargs):
super(InterfaceForm, self).__init__(*args, **kwargs)
class InterfaceCreateForm(BootstrapMixin, forms.Form):
# Limit LAG choices to interfaces belonging to this device
if self.is_bound:
self.fields['lag'].queryset = Interface.objects.order_naturally().filter(
device_id=self.data['device'], form_factor=IFACE_FF_LAG
)
else:
self.fields['lag'].queryset = Interface.objects.order_naturally().filter(
device=self.instance.device, form_factor=IFACE_FF_LAG
)
class InterfaceCreateForm(DeviceComponentForm):
name_pattern = ExpandableNameField(label='Name')
form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES)
lag = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Parent LAG')
mac_address = MACAddressFormField(required=False, label='MAC Address')
mgmt_only = forms.BooleanField(required=False, label='OOB Management')
description = forms.CharField(max_length=100, required=False)
def __init__(self, *args, **kwargs):
super(InterfaceCreateForm, self).__init__(*args, **kwargs)
# Limit LAG choices to interfaces belonging to this device
if self.device is not None:
self.fields['lag'].queryset = Interface.objects.order_naturally().filter(
device=self.device, form_factor=IFACE_FF_LAG
)
else:
self.fields['lag'].queryset = Interface.objects.none()
class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
device = forms.ModelChoiceField(queryset=Device.objects.all(), widget=forms.HiddenInput)
lag = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Parent LAG')
form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False)
description = forms.CharField(max_length=100, required=False)
class Meta:
nullable_fields = ['description']
nullable_fields = ['lag', 'description']
def __init__(self, *args, **kwargs):
super(InterfaceBulkEditForm, self).__init__(*args, **kwargs)
# Limit LAG choices to interfaces which belong to the parent device.
if self.initial.get('device'):
self.fields['lag'].queryset = Interface.objects.filter(
device=self.initial['device'], form_factor=IFACE_FF_LAG
)
else:
self.fields['lag'].choices = []
#
@@ -1094,22 +1399,55 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
#
class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm):
interface_a = forms.ChoiceField(choices=[], widget=SelectWithDisabled, label='Interface')
site_b = forms.ModelChoiceField(queryset=Site.objects.all(), label='Site', required=False,
widget=forms.Select(attrs={'filter-for': 'rack_b'}))
rack_b = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False,
widget=APISelect(api_url='/api/dcim/racks/?site_id={{site_b}}',
attrs={'filter-for': 'device_b'}))
device_b = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False,
widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack_b}}',
display_field='display_name',
attrs={'filter-for': 'interface_b'}))
livesearch = forms.CharField(required=False, label='Device', widget=Livesearch(
query_key='q', query_url='dcim-api:device_list', field_to_update='device_b')
interface_a = forms.ChoiceField(
choices=[],
widget=SelectWithDisabled,
label='Interface'
)
site_b = forms.ModelChoiceField(
queryset=Site.objects.all(),
label='Site',
required=False,
widget=forms.Select(
attrs={'filter-for': 'rack_b'}
)
)
rack_b = forms.ModelChoiceField(
queryset=Rack.objects.all(),
label='Rack',
required=False,
widget=APISelect(
api_url='/api/dcim/racks/?site_id={{site_b}}',
attrs={'filter-for': 'device_b', 'nullable': 'true'}
)
)
device_b = forms.ModelChoiceField(
queryset=Device.objects.all(),
label='Device',
required=False,
widget=APISelect(
api_url='/api/dcim/devices/?site_id={{site_b}}&rack_id={{rack_b}}',
display_field='display_name',
attrs={'filter-for': 'interface_b'}
)
)
livesearch = forms.CharField(
required=False,
label='Device',
widget=Livesearch(
query_key='q',
query_url='dcim-api:device_list',
field_to_update='device_b'
)
)
interface_b = forms.ModelChoiceField(
queryset=Interface.objects.all(),
label='Interface',
widget=APISelect(
api_url='/api/dcim/devices/{{device_b}}/interfaces/?type=physical',
disabled_indicator='is_connected'
)
)
interface_b = forms.ModelChoiceField(queryset=Interface.objects.all(), label='Interface',
widget=APISelect(api_url='/api/dcim/devices/{{device_b}}/interfaces/?type=physical',
disabled_indicator='is_connected'))
class Meta:
model = InterfaceConnection
@@ -1120,37 +1458,36 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm):
super(InterfaceConnectionForm, self).__init__(*args, **kwargs)
# Initialize interface A choices
device_a_interfaces = Interface.objects.filter(device=device_a).exclude(form_factor=IFACE_FF_VIRTUAL)\
.select_related('circuit_termination', 'connected_as_a', 'connected_as_b')
device_a_interfaces = Interface.objects.filter(device=device_a).exclude(
form_factor__in=VIRTUAL_IFACE_TYPES
).select_related(
'circuit_termination', 'connected_as_a', 'connected_as_b'
)
self.fields['interface_a'].choices = [
(iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in device_a_interfaces
]
# Initialize rack_b choices if site_b is set
if self.is_bound and self.data.get('site_b'):
self.fields['rack_b'].queryset = Rack.objects.filter(site__pk=self.data['site_b'])
elif self.initial.get('site_b'):
if self.initial.get('site_b'):
self.fields['rack_b'].queryset = Rack.objects.filter(site=self.initial['site_b'])
else:
self.fields['rack_b'].choices = []
# Initialize device_b choices if rack_b is set
if self.is_bound and self.data.get('rack_b'):
self.fields['device_b'].queryset = Device.objects.filter(rack__pk=self.data['rack_b'])
elif self.initial.get('rack_b'):
# Initialize device_b choices if rack_b or site_b is set
if self.initial.get('rack_b'):
self.fields['device_b'].queryset = Device.objects.filter(rack=self.initial['rack_b'])
elif self.initial.get('site_b'):
self.fields['device_b'].queryset = Device.objects.filter(site=self.initial['site_b'], rack__isnull=True)
else:
self.fields['device_b'].choices = []
# Initialize interface_b choices if device_b is set
if self.is_bound:
device_b_interfaces = Interface.objects.filter(device=self.data['device_b'])\
.exclude(form_factor=IFACE_FF_VIRTUAL)\
.select_related('circuit_termination', 'connected_as_a', 'connected_as_b')
elif self.initial.get('device_b'):
device_b_interfaces = Interface.objects.filter(device=self.initial['device_b'])\
.exclude(form_factor=IFACE_FF_VIRTUAL)\
.select_related('circuit_termination', 'connected_as_a', 'connected_as_b')
if self.initial.get('device_b'):
device_b_interfaces = Interface.objects.filter(device=self.initial['device_b']).exclude(
form_factor__in=VIRTUAL_IFACE_TYPES
).select_related(
'circuit_termination', 'connected_as_a', 'connected_as_b'
)
else:
device_b_interfaces = []
self.fields['interface_b'].choices = [
@@ -1159,13 +1496,21 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm):
class InterfaceConnectionCSVForm(forms.Form):
device_a = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Device A not found.'})
device_a = FlexibleModelChoiceField(
queryset=Device.objects.all(),
to_field_name='name',
error_messages={'invalid_choice': 'Device A not found.'}
)
interface_a = forms.CharField()
device_b = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Device B not found.'})
device_b = FlexibleModelChoiceField(
queryset=Device.objects.all(),
to_field_name='name',
error_messages={'invalid_choice': 'Device B not found.'}
)
interface_b = forms.CharField()
status = forms.CharField(validators=[validate_connection_status])
status = forms.CharField(
validators=[validate_connection_status]
)
def clean(self):
@@ -1260,7 +1605,7 @@ class DeviceBayForm(BootstrapMixin, forms.ModelForm):
}
class DeviceBayCreateForm(BootstrapMixin, forms.Form):
class DeviceBayCreateForm(DeviceComponentForm):
name_pattern = ExpandableNameField(label='Name')

View File

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2017-01-06 16:56
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0024_site_add_contact_fields'),
]
operations = [
migrations.AddField(
model_name='devicetype',
name='interface_ordering',
field=models.PositiveSmallIntegerField(choices=[[1, b'Slot/position'], [2, b'Name (alphabetically)']], default=1),
),
]

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,31 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2017-02-27 19:55
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('dcim', '0029_allow_rackless_devices'),
]
operations = [
migrations.AddField(
model_name='interface',
name='lag',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='member_interfaces', to='dcim.Interface', verbose_name=b'Parent LAG'),
),
migrations.AlterField(
model_name='interface',
name='form_factor',
field=models.PositiveSmallIntegerField(choices=[[b'Virtual interfaces', [[0, b'Virtual'], [200, b'Link Aggregation Group (LAG)']]], [b'Ethernet (fixed)', [[800, b'100BASE-TX (10/100ME)'], [1000, b'1000BASE-T (1GE)'], [1150, b'10GBASE-T (10GE)']]], [b'Ethernet (modular)', [[1050, b'GBIC (1GE)'], [1100, b'SFP (1GE)'], [1200, b'SFP+ (10GE)'], [1300, b'XFP (10GE)'], [1310, b'XENPAK (10GE)'], [1320, b'X2 (10GE)'], [1350, b'SFP28 (25GE)'], [1400, b'QSFP+ (40GE)'], [1500, b'CFP (100GE)'], [1600, b'QSFP28 (100GE)']]], [b'FibreChannel', [[3010, b'SFP (1GFC)'], [3020, b'SFP (2GFC)'], [3040, b'SFP (4GFC)'], [3080, b'SFP+ (8GFC)'], [3160, b'SFP+ (16GFC)']]], [b'Serial', [[4000, b'T1 (1.544 Mbps)'], [4010, b'E1 (2.048 Mbps)'], [4040, b'T3 (45 Mbps)'], [4050, b'E3 (34 Mbps)'], [4050, b'E3 (34 Mbps)']]], [b'Stacking', [[5000, b'Cisco StackWise'], [5050, b'Cisco StackWise Plus'], [5100, b'Cisco FlexStack'], [5150, b'Cisco FlexStack Plus']]], [b'Other', [[32767, b'Other']]]], default=1200),
),
migrations.AlterField(
model_name='interfacetemplate',
name='form_factor',
field=models.PositiveSmallIntegerField(choices=[[b'Virtual interfaces', [[0, b'Virtual'], [200, b'Link Aggregation Group (LAG)']]], [b'Ethernet (fixed)', [[800, b'100BASE-TX (10/100ME)'], [1000, b'1000BASE-T (1GE)'], [1150, b'10GBASE-T (10GE)']]], [b'Ethernet (modular)', [[1050, b'GBIC (1GE)'], [1100, b'SFP (1GE)'], [1200, b'SFP+ (10GE)'], [1300, b'XFP (10GE)'], [1310, b'XENPAK (10GE)'], [1320, b'X2 (10GE)'], [1350, b'SFP28 (25GE)'], [1400, b'QSFP+ (40GE)'], [1500, b'CFP (100GE)'], [1600, b'QSFP28 (100GE)']]], [b'FibreChannel', [[3010, b'SFP (1GFC)'], [3020, b'SFP (2GFC)'], [3040, b'SFP (4GFC)'], [3080, b'SFP+ (8GFC)'], [3160, b'SFP+ (16GFC)']]], [b'Serial', [[4000, b'T1 (1.544 Mbps)'], [4010, b'E1 (2.048 Mbps)'], [4040, b'T3 (45 Mbps)'], [4050, b'E3 (34 Mbps)'], [4050, b'E3 (34 Mbps)']]], [b'Stacking', [[5000, b'Cisco StackWise'], [5050, b'Cisco StackWise Plus'], [5100, b'Cisco FlexStack'], [5150, b'Cisco FlexStack Plus']]], [b'Other', [[32767, b'Other']]]], default=1200),
),
]

View File

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

View File

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

View File

@@ -1,13 +1,18 @@
from collections import OrderedDict
from mptt.models import MPTTModel, TreeForeignKey
from django.conf import settings
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ValidationError
from django.core.urlresolvers import reverse
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import Count, Q, ObjectDoesNotExist
from django.utils.encoding import python_2_unicode_compatible
from circuits.models import Circuit
from extras.models import CustomFieldModel, CustomField, CustomFieldValue
@@ -56,8 +61,16 @@ SUBDEVICE_ROLE_CHOICES = (
(SUBDEVICE_ROLE_CHILD, 'Child'),
)
IFACE_ORDERING_POSITION = 1
IFACE_ORDERING_NAME = 2
IFACE_ORDERING_CHOICES = [
[IFACE_ORDERING_POSITION, 'Slot/position'],
[IFACE_ORDERING_NAME, 'Name (alphabetically)']
]
# Virtual
IFACE_FF_VIRTUAL = 0
IFACE_FF_LAG = 200
# Ethernet
IFACE_FF_100ME_FIXED = 800
IFACE_FF_1GE_FIXED = 1000
@@ -96,6 +109,7 @@ IFACE_FF_CHOICES = [
'Virtual interfaces',
[
[IFACE_FF_VIRTUAL, 'Virtual'],
[IFACE_FF_LAG, 'Link Aggregation Group (LAG)'],
]
],
[
@@ -138,6 +152,7 @@ IFACE_FF_CHOICES = [
[IFACE_FF_E1, 'E1 (2.048 Mbps)'],
[IFACE_FF_T3, 'T3 (45 Mbps)'],
[IFACE_FF_E3, 'E3 (34 Mbps)'],
[IFACE_FF_E3, 'E3 (34 Mbps)'],
]
],
[
@@ -157,6 +172,11 @@ IFACE_FF_CHOICES = [
],
]
VIRTUAL_IFACE_TYPES = [
IFACE_FF_VIRTUAL,
IFACE_FF_LAG,
]
STATUS_ACTIVE = True
STATUS_OFFLINE = False
STATUS_CHOICES = [
@@ -182,46 +202,27 @@ RPC_CLIENT_CHOICES = [
]
def order_interfaces(queryset, sql_col, primary_ordering=tuple()):
#
# Regions
#
@python_2_unicode_compatible
class Region(MPTTModel):
"""
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)
Sites can be grouped within geographic Regions.
"""
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)
parent = TreeForeignKey('self', null=True, blank=True, related_name='children', db_index=True)
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
class MPTTMeta:
order_insertion_by = ['name']
def __str__(self):
return self.name
def get_absolute_url(self):
return "{}?region={}".format(reverse('dcim:site_list'), self.slug)
#
@@ -234,6 +235,7 @@ class SiteManager(NaturalOrderByManager):
return self.natural_order_by('name')
@python_2_unicode_compatible
class Site(CreatedUpdatedModel, CustomFieldModel):
"""
A Site represents a geographic location within a network; typically a building or campus. The optional facility
@@ -241,7 +243,8 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
"""
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
tenant = models.ForeignKey(Tenant, blank=True, null=True, related_name='sites', on_delete=models.PROTECT)
region = models.ForeignKey('Region', related_name='sites', blank=True, null=True, on_delete=models.SET_NULL)
tenant = models.ForeignKey(Tenant, related_name='sites', blank=True, null=True, on_delete=models.PROTECT)
facility = models.CharField(max_length=50, blank=True)
asn = ASNField(blank=True, null=True, verbose_name='ASN')
physical_address = models.CharField(max_length=200, blank=True)
@@ -257,7 +260,7 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
class Meta:
ordering = ['name']
def __unicode__(self):
def __str__(self):
return self.name
def get_absolute_url(self):
@@ -267,6 +270,7 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
return csv_format([
self.name,
self.slug,
self.region.name if self.region else None,
self.tenant.name if self.tenant else None,
self.facility,
self.asn,
@@ -300,6 +304,7 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
# Racks
#
@python_2_unicode_compatible
class RackGroup(models.Model):
"""
Racks can be grouped as subsets within a Site. The scope of a group will depend on how Sites are defined. For
@@ -317,13 +322,14 @@ class RackGroup(models.Model):
['site', 'slug'],
]
def __unicode__(self):
def __str__(self):
return u'{} - {}'.format(self.site.name, self.name)
def get_absolute_url(self):
return "{}?group_id={}".format(reverse('dcim:rack_list'), self.pk)
@python_2_unicode_compatible
class RackRole(models.Model):
"""
Racks can be organized by functional role, similar to Devices.
@@ -335,7 +341,7 @@ class RackRole(models.Model):
class Meta:
ordering = ['name']
def __unicode__(self):
def __str__(self):
return self.name
def get_absolute_url(self):
@@ -348,6 +354,7 @@ class RackManager(NaturalOrderByManager):
return self.natural_order_by('site__name', 'name')
@python_2_unicode_compatible
class Rack(CreatedUpdatedModel, CustomFieldModel):
"""
Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
@@ -378,7 +385,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
['site', 'facility_id'],
]
def __unicode__(self):
def __str__(self):
return self.display_name
def get_absolute_url(self):
@@ -398,6 +405,19 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
)
})
def save(self, *args, **kwargs):
# Record the original site assignment for this rack.
_site_id = None
if self.pk:
_site_id = Rack.objects.get(pk=self.pk).site_id
super(Rack, self).save(*args, **kwargs)
# Update racked devices if the assigned Site has been changed.
if _site_id is not None and self.site_id != _site_id:
Device.objects.filter(rack=self).update(site_id=self.site.pk)
def to_csv(self):
return csv_format([
self.site.name,
@@ -477,7 +497,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
devices = self.devices.select_related('device_type').filter(position__gte=1).exclude(pk__in=exclude)
# Initialize the rack unit skeleton
units = range(1, self.u_height + 1)
units = list(range(1, self.u_height + 1))
# Remove units consumed by installed devices
for d in devices:
@@ -508,10 +528,55 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
return int(float(self.u_height - u_available) / self.u_height * 100)
@python_2_unicode_compatible
class RackReservation(models.Model):
"""
One or more reserved units within a Rack.
"""
rack = models.ForeignKey('Rack', related_name='reservations', editable=False, on_delete=models.CASCADE)
units = ArrayField(models.PositiveSmallIntegerField())
created = models.DateTimeField(auto_now_add=True)
user = models.ForeignKey(User, editable=False, on_delete=models.PROTECT)
description = models.CharField(max_length=100)
class Meta:
ordering = ['created']
def __str__(self):
return u"Reservation for rack {}".format(self.rack)
def clean(self):
if self.units:
# Validate that all specified units exist in the Rack.
invalid_units = [u for u in self.units if u not in self.rack.units]
if invalid_units:
raise ValidationError({
'units': u"Invalid unit(s) for {}U rack: {}".format(
self.rack.u_height,
', '.join([str(u) for u in invalid_units]),
),
})
# Check that none of the units has already been reserved for this Rack.
reserved_units = []
for resv in self.rack.reservations.exclude(pk=self.pk):
reserved_units += resv.units
conflicting_units = [u for u in self.units if u in reserved_units]
if conflicting_units:
raise ValidationError({
'units': 'The following units have already been reserved: {}'.format(
', '.join([str(u) for u in conflicting_units]),
)
})
#
# Device Types
#
@python_2_unicode_compatible
class Manufacturer(models.Model):
"""
A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell.
@@ -522,13 +587,14 @@ class Manufacturer(models.Model):
class Meta:
ordering = ['name']
def __unicode__(self):
def __str__(self):
return self.name
def get_absolute_url(self):
return "{}?manufacturer={}".format(reverse('dcim:devicetype_list'), self.slug)
@python_2_unicode_compatible
class DeviceType(models.Model, CustomFieldModel):
"""
A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as
@@ -551,6 +617,8 @@ class DeviceType(models.Model, CustomFieldModel):
u_height = models.PositiveSmallIntegerField(verbose_name='Height (U)', default=1)
is_full_depth = models.BooleanField(default=True, verbose_name="Is full depth",
help_text="Device consumes both front and rear rack faces")
interface_ordering = models.PositiveSmallIntegerField(choices=IFACE_ORDERING_CHOICES,
default=IFACE_ORDERING_POSITION)
is_console_server = models.BooleanField(default=False, verbose_name='Is a console server',
help_text="This type of device has console server ports")
is_pdu = models.BooleanField(default=False, verbose_name='Is a PDU',
@@ -571,7 +639,7 @@ class DeviceType(models.Model, CustomFieldModel):
['manufacturer', 'slug'],
]
def __unicode__(self):
def __str__(self):
return self.model
def __init__(self, *args, **kwargs):
@@ -641,6 +709,7 @@ class DeviceType(models.Model, CustomFieldModel):
return bool(self.subdevice_role is False)
@python_2_unicode_compatible
class ConsolePortTemplate(models.Model):
"""
A template for a ConsolePort to be created for a new Device.
@@ -652,10 +721,11 @@ class ConsolePortTemplate(models.Model):
ordering = ['device_type', 'name']
unique_together = ['device_type', 'name']
def __unicode__(self):
def __str__(self):
return self.name
@python_2_unicode_compatible
class ConsoleServerPortTemplate(models.Model):
"""
A template for a ConsoleServerPort to be created for a new Device.
@@ -667,10 +737,11 @@ class ConsoleServerPortTemplate(models.Model):
ordering = ['device_type', 'name']
unique_together = ['device_type', 'name']
def __unicode__(self):
def __str__(self):
return self.name
@python_2_unicode_compatible
class PowerPortTemplate(models.Model):
"""
A template for a PowerPort to be created for a new Device.
@@ -682,10 +753,11 @@ class PowerPortTemplate(models.Model):
ordering = ['device_type', 'name']
unique_together = ['device_type', 'name']
def __unicode__(self):
def __str__(self):
return self.name
@python_2_unicode_compatible
class PowerOutletTemplate(models.Model):
"""
A template for a PowerOutlet to be created for a new Device.
@@ -697,17 +769,49 @@ class PowerOutletTemplate(models.Model):
ordering = ['device_type', 'name']
unique_together = ['device_type', 'name']
def __unicode__(self):
def __str__(self):
return self.name
class InterfaceTemplateManager(models.Manager):
class InterfaceManager(models.Manager):
def get_queryset(self):
qs = super(InterfaceTemplateManager, self).get_queryset()
return order_interfaces(qs, 'dcim_interfacetemplate.name', ('device_type',))
def order_naturally(self, method=IFACE_ORDERING_POSITION):
"""
Naturally order interfaces by their name and numeric position. The sort method must be one of the defined
IFACE_ORDERING_CHOICES (typically indicated by a parent Device's DeviceType).
To order interfaces naturally, the `name` field is split into five distinct components: leading text (name),
slot, subslot, position, and channel:
{name}{slot}/{subslot}/{position}:{channel}
Components absent from the interface name are ignored. For example, an interface named GigabitEthernet0/1 would
be parsed as follows:
name = 'GigabitEthernet'
slot = None
subslot = 0
position = 1
channel = None
The chosen sorting method will determine which fields are ordered first in the query.
"""
queryset = self.get_queryset()
sql_col = '{}.name'.format(queryset.model._meta.db_table)
ordering = {
IFACE_ORDERING_POSITION: ('_slot', '_subslot', '_position', '_channel', '_name'),
IFACE_ORDERING_NAME: ('_name', '_slot', '_subslot', '_position', '_channel'),
}[method]
return queryset.extra(select={
'_name': "SUBSTRING({} FROM '^([^0-9]+)')".format(sql_col),
'_slot': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+\/[0-9]+(:[0-9]+)?$') AS integer)".format(sql_col),
'_subslot': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+(:[0-9]+)?$') AS integer)".format(sql_col),
'_position': "CAST(SUBSTRING({} FROM '([0-9]+)(:[0-9]+)?$') AS integer)".format(sql_col),
'_channel': "CAST(SUBSTRING({} FROM ':([0-9]+)$') AS integer)".format(sql_col),
}).order_by(*ordering)
@python_2_unicode_compatible
class InterfaceTemplate(models.Model):
"""
A template for a physical data interface on a new Device.
@@ -717,16 +821,17 @@ class InterfaceTemplate(models.Model):
form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_10GE_SFP_PLUS)
mgmt_only = models.BooleanField(default=False, verbose_name='Management only')
objects = InterfaceTemplateManager()
objects = InterfaceManager()
class Meta:
ordering = ['device_type', 'name']
unique_together = ['device_type', 'name']
def __unicode__(self):
def __str__(self):
return self.name
@python_2_unicode_compatible
class DeviceBayTemplate(models.Model):
"""
A template for a DeviceBay to be created for a new parent Device.
@@ -738,7 +843,7 @@ class DeviceBayTemplate(models.Model):
ordering = ['device_type', 'name']
unique_together = ['device_type', 'name']
def __unicode__(self):
def __str__(self):
return self.name
@@ -746,6 +851,7 @@ class DeviceBayTemplate(models.Model):
# Devices
#
@python_2_unicode_compatible
class DeviceRole(models.Model):
"""
Devices are organized by functional role; for example, "Core Switch" or "File Server". Each DeviceRole is assigned a
@@ -758,13 +864,14 @@ class DeviceRole(models.Model):
class Meta:
ordering = ['name']
def __unicode__(self):
def __str__(self):
return self.name
def get_absolute_url(self):
return "{}?role={}".format(reverse('dcim:device_list'), self.slug)
@python_2_unicode_compatible
class Platform(models.Model):
"""
Platform refers to the software or firmware running on a Device; for example, "Cisco IOS-XR" or "Juniper Junos".
@@ -778,7 +885,7 @@ class Platform(models.Model):
class Meta:
ordering = ['name']
def __unicode__(self):
def __str__(self):
return self.name
def get_absolute_url(self):
@@ -791,6 +898,7 @@ class DeviceManager(NaturalOrderByManager):
return self.natural_order_by('name')
@python_2_unicode_compatible
class Device(CreatedUpdatedModel, CustomFieldModel):
"""
A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType,
@@ -807,11 +915,12 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
device_role = models.ForeignKey('DeviceRole', related_name='devices', on_delete=models.PROTECT)
tenant = models.ForeignKey(Tenant, blank=True, null=True, related_name='devices', on_delete=models.PROTECT)
platform = models.ForeignKey('Platform', related_name='devices', blank=True, null=True, on_delete=models.SET_NULL)
name = NullableCharField(max_length=50, blank=True, null=True, unique=True)
name = NullableCharField(max_length=64, blank=True, null=True, unique=True)
serial = models.CharField(max_length=50, blank=True, verbose_name='Serial number')
asset_tag = NullableCharField(max_length=50, blank=True, null=True, unique=True, verbose_name='Asset tag',
help_text='A unique tag used to identify this device')
rack = models.ForeignKey('Rack', related_name='devices', on_delete=models.PROTECT)
site = models.ForeignKey('Site', related_name='devices', on_delete=models.PROTECT)
rack = models.ForeignKey('Rack', related_name='devices', blank=True, null=True, on_delete=models.PROTECT)
position = models.PositiveSmallIntegerField(blank=True, null=True, validators=[MinValueValidator(1)],
verbose_name='Position (U)',
help_text='The lowest-numbered unit occupied by the device')
@@ -830,7 +939,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
ordering = ['name']
unique_together = ['rack', 'position', 'face']
def __unicode__(self):
def __str__(self):
return self.display_name
def get_absolute_url(self):
@@ -838,41 +947,59 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
def clean(self):
# Validate site/rack combination
if self.rack and self.site != self.rack.site:
raise ValidationError({
'rack': "Rack {} does not belong to site {}.".format(self.rack, self.site),
})
if self.rack is None:
if self.face is not None:
raise ValidationError({
'face': "Cannot select a rack face without assigning a rack.",
})
if self.position:
raise ValidationError({
'face': "Cannot select a rack position without assigning a rack.",
})
# Validate position/face combination
if self.position and self.face is None:
raise ValidationError({
'face': "Must specify rack face when defining rack position."
'face': "Must specify rack face when defining rack position.",
})
try:
# Child devices cannot be assigned to a rack face/unit
if self.device_type.is_child_device and self.face is not None:
raise ValidationError({
'face': "Child device types cannot be assigned to a rack face. This is an attribute of the parent "
"device."
})
if self.device_type.is_child_device and self.position:
raise ValidationError({
'position': "Child device types cannot be assigned to a rack position. This is an attribute of the "
"parent device."
})
if self.rack:
# Validate rack space
rack_face = self.face if not self.device_type.is_full_depth else None
exclude_list = [self.pk] if self.pk else []
try:
available_units = self.rack.get_available_units(u_height=self.device_type.u_height, rack_face=rack_face,
exclude=exclude_list)
if self.position and self.position not in available_units:
# Child devices cannot be assigned to a rack face/unit
if self.device_type.is_child_device and self.face is not None:
raise ValidationError({
'position': "U{} is already occupied or does not have sufficient space to accommodate a(n) {} "
"({}U).".format(self.position, self.device_type, self.device_type.u_height)
'face': "Child device types cannot be assigned to a rack face. This is an attribute of the parent "
"device."
})
if self.device_type.is_child_device and self.position:
raise ValidationError({
'position': "Child device types cannot be assigned to a rack position. This is an attribute of the "
"parent device."
})
except Rack.DoesNotExist:
pass
except DeviceType.DoesNotExist:
pass
# Validate rack space
rack_face = self.face if not self.device_type.is_full_depth else None
exclude_list = [self.pk] if self.pk else []
try:
available_units = self.rack.get_available_units(u_height=self.device_type.u_height, rack_face=rack_face,
exclude=exclude_list)
if self.position and self.position not in available_units:
raise ValidationError({
'position': "U{} is already occupied or does not have sufficient space to accommodate a(n) {} "
"({}U).".format(self.position, self.device_type, self.device_type.u_height)
})
except Rack.DoesNotExist:
pass
except DeviceType.DoesNotExist:
pass
def save(self, *args, **kwargs):
@@ -920,8 +1047,8 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
self.platform.name if self.platform else None,
self.serial,
self.asset_tag,
self.rack.site.name,
self.rack.name,
self.site.name,
self.rack.name if self.rack else None,
self.position,
self.get_face_display(),
])
@@ -970,6 +1097,11 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
return RPC_CLIENTS.get(self.platform.rpc_client)
#
# Console ports
#
@python_2_unicode_compatible
class ConsolePort(models.Model):
"""
A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
@@ -984,12 +1116,9 @@ class ConsolePort(models.Model):
ordering = ['device', 'name']
unique_together = ['device', 'name']
def __unicode__(self):
def __str__(self):
return self.name
def get_parent_url(self):
return self.device.get_absolute_url()
# Used for connections export
def to_csv(self):
return csv_format([
@@ -1001,6 +1130,10 @@ class ConsolePort(models.Model):
])
#
# Console server ports
#
class ConsoleServerPortManager(models.Manager):
def get_queryset(self):
@@ -1016,6 +1149,7 @@ class ConsoleServerPortManager(models.Manager):
}).order_by('device', 'name_as_integer')
@python_2_unicode_compatible
class ConsoleServerPort(models.Model):
"""
A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
@@ -1028,13 +1162,15 @@ class ConsoleServerPort(models.Model):
class Meta:
unique_together = ['device', 'name']
def __unicode__(self):
def __str__(self):
return self.name
def get_parent_url(self):
return self.device.get_absolute_url()
#
# Power ports
#
@python_2_unicode_compatible
class PowerPort(models.Model):
"""
A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
@@ -1049,15 +1185,12 @@ class PowerPort(models.Model):
ordering = ['device', 'name']
unique_together = ['device', 'name']
def __unicode__(self):
def __str__(self):
return self.name
def get_parent_url(self):
return self.device.get_absolute_url()
# Used for connections export
def csv_format(self):
return ','.join([
def to_csv(self):
return csv_format([
self.power_outlet.device.identifier if self.power_outlet else None,
self.power_outlet.name if self.power_outlet else None,
self.device.identifier,
@@ -1066,6 +1199,10 @@ class PowerPort(models.Model):
])
#
# Power outlets
#
class PowerOutletManager(models.Manager):
def get_queryset(self):
@@ -1075,6 +1212,7 @@ class PowerOutletManager(models.Manager):
}).order_by('device', 'name_padded')
@python_2_unicode_compatible
class PowerOutlet(models.Model):
"""
A physical power outlet (output) within a Device which provides power to a PowerPort.
@@ -1087,32 +1225,23 @@ class PowerOutlet(models.Model):
class Meta:
unique_together = ['device', 'name']
def __unicode__(self):
def __str__(self):
return self.name
def get_parent_url(self):
return self.device.get_absolute_url()
class InterfaceManager(models.Manager):
def get_queryset(self):
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)
def physical(self):
return self.get_queryset().exclude(form_factor=IFACE_FF_VIRTUAL)
#
# Interfaces
#
@python_2_unicode_compatible
class Interface(models.Model):
"""
A physical data interface within a Device. An Interface can connect to exactly one other Interface via the creation
of an InterfaceConnection.
"""
device = models.ForeignKey('Device', related_name='interfaces', on_delete=models.CASCADE)
lag = models.ForeignKey('self', related_name='member_interfaces', null=True, blank=True, on_delete=models.SET_NULL,
verbose_name='Parent LAG')
name = models.CharField(max_length=30)
form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_10GE_SFP_PLUS)
mac_address = MACAddressField(null=True, blank=True, verbose_name='MAC Address')
@@ -1126,23 +1255,47 @@ class Interface(models.Model):
ordering = ['device', 'name']
unique_together = ['device', 'name']
def __unicode__(self):
def __str__(self):
return self.name
def get_parent_url(self):
return self.device.get_absolute_url()
def clean(self):
if self.form_factor == IFACE_FF_VIRTUAL and self.is_connected:
# Virtual interfaces cannot be connected
if self.form_factor in VIRTUAL_IFACE_TYPES and self.is_connected:
raise ValidationError({
'form_factor': "Virtual interfaces cannot be connected to another interface or circuit. Disconnect the "
"interface or choose a physical form factor."
})
# An interface's LAG must belong to the same device
if self.lag and self.lag.device != self.device:
raise ValidationError({
'lag': u"The selected LAG interface ({}) belongs to a different device ({}).".format(
self.lag.name, self.lag.device.name
)
})
# A virtual interface cannot have a parent LAG
if self.form_factor in VIRTUAL_IFACE_TYPES and self.lag is not None:
raise ValidationError({
'lag': u"{} interfaces cannot have a parent LAG interface.".format(self.get_form_factor_display())
})
# Only a LAG can have LAG members
if self.form_factor != IFACE_FF_LAG and self.member_interfaces.exists():
raise ValidationError({
'form_factor': "Cannot change interface form factor; it has LAG members ({}).".format(
u", ".join([iface.name for iface in self.member_interfaces.all()])
)
})
@property
def is_physical(self):
return self.form_factor != IFACE_FF_VIRTUAL
def is_virtual(self):
return self.form_factor in VIRTUAL_IFACE_TYPES
@property
def is_lag(self):
return self.form_factor == IFACE_FF_LAG
@property
def is_connected(self):
@@ -1206,6 +1359,11 @@ class InterfaceConnection(models.Model):
])
#
# Device bays
#
@python_2_unicode_compatible
class DeviceBay(models.Model):
"""
An empty space within a Device which can house a child device
@@ -1219,12 +1377,9 @@ class DeviceBay(models.Model):
ordering = ['device', 'name']
unique_together = ['device', 'name']
def __unicode__(self):
def __str__(self):
return u'{} - {}'.format(self.device.name, self.name)
def get_parent_url(self):
return self.device.get_absolute_url()
def clean(self):
# Validate that the parent Device can have DeviceBays
@@ -1238,6 +1393,11 @@ class DeviceBay(models.Model):
raise ValidationError("Cannot install a device into itself.")
#
# Modules
#
@python_2_unicode_compatible
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
@@ -1256,8 +1416,5 @@ class Module(models.Model):
ordering = ['device__id', 'parent__id', 'name']
unique_together = ['device', 'parent', 'name']
def __unicode__(self):
def __str__(self):
return self.name
def get_parent_url(self):
return reverse('dcim:device_inventory', args=[self.device.pk])

View File

@@ -6,10 +6,28 @@ from utilities.tables import BaseTable, ToggleColumn
from .models import (
ConsolePort, ConsolePortTemplate, ConsoleServerPortTemplate, Device, DeviceBayTemplate, DeviceRole, DeviceType,
Interface, InterfaceTemplate, Manufacturer, Platform, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack,
RackGroup, Site,
RackGroup, Region, Site,
)
REGION_LINK = """
{% if record.get_children %}
<span style="padding-left: {{ record.get_ancestors|length }}0px "><i class="fa fa-caret-right"></i></a>
{% else %}
<span style="padding-left: {{ record.get_ancestors|length }}9px">
{% endif %}
{{ record.name }}
</span>
"""
SITE_REGION_LINK = """
{% if record.region %}
<a href="{% url 'dcim:site_list' %}?region={{ record.region.slug }}">{{ record.region }}</a>
{% else %}
&mdash;
{% endif %}
"""
COLOR_LABEL = """
<label class="label" style="background-color: #{{ record.color }}">{{ record }}</label>
"""
@@ -20,6 +38,12 @@ DEVICE_LINK = """
</a>
"""
REGION_ACTIONS = """
{% if perms.dcim.change_region %}
<a href="{% url 'dcim:region_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
"""
RACKGROUP_ACTIONS = """
{% if perms.dcim.change_rackgroup %}
<a href="{% url 'dcim:rackgroup_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
@@ -70,12 +94,38 @@ STATUS_ICON = """
{% endif %}
"""
DEVICE_PRIMARY_IP = """
{{ record.primary_ip6.address.ip|default:"" }}
{% if record.primary_ip6 and record.primary_ip4 %}<br />{% endif %}
{{ record.primary_ip4.address.ip|default:"" }}
"""
UTILIZATION_GRAPH = """
{% load helpers %}
{% utilization_graph value %}
"""
#
# Regions
#
class RegionTable(BaseTable):
pk = ToggleColumn()
name = tables.TemplateColumn(template_code=REGION_LINK, orderable=False)
site_count = tables.Column(verbose_name='Sites')
slug = tables.Column(verbose_name='Slug')
actions = tables.TemplateColumn(
template_code=REGION_ACTIONS,
attrs={'td': {'class': 'text-right'}},
verbose_name=''
)
class Meta(BaseTable.Meta):
model = Region
fields = ('pk', 'name', 'site_count', 'slug', 'actions')
#
# Sites
#
@@ -84,6 +134,7 @@ class SiteTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn('dcim:site', args=[Accessor('slug')], verbose_name='Name')
facility = tables.Column(verbose_name='Facility')
region = tables.TemplateColumn(template_code=SITE_REGION_LINK, verbose_name='Region')
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
asn = tables.Column(verbose_name='ASN')
rack_count = tables.Column(accessor=Accessor('count_racks'), orderable=False, verbose_name='Racks')
@@ -94,8 +145,10 @@ class SiteTable(BaseTable):
class Meta(BaseTable.Meta):
model = Site
fields = ('pk', 'name', 'facility', 'tenant', 'asn', 'rack_count', 'device_count', 'prefix_count',
'vlan_count', 'circuit_count')
fields = (
'pk', 'name', 'facility', 'region', 'tenant', 'asn', 'rack_count', 'device_count', 'prefix_count',
'vlan_count', 'circuit_count',
)
#
@@ -311,13 +364,13 @@ class DeviceTable(BaseTable):
status = tables.TemplateColumn(template_code=STATUS_ICON, verbose_name='')
name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name')
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
site = tables.Column(accessor=Accessor('rack.site'), verbose_name='Site')
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack')
device_role = tables.TemplateColumn(DEVICE_ROLE, verbose_name='Role')
device_type = tables.LinkColumn('dcim:devicetype', args=[Accessor('device_type.pk')], verbose_name='Type',
text=lambda record: record.device_type.full_name)
primary_ip = tables.TemplateColumn(orderable=False, verbose_name='IP Address',
template_code="{{ record.primary_ip.address.ip }}")
template_code=DEVICE_PRIMARY_IP)
class Meta(BaseTable.Meta):
model = Device
@@ -327,7 +380,7 @@ class DeviceTable(BaseTable):
class DeviceImportTable(BaseTable):
name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name')
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
site = tables.Column(accessor=Accessor('rack.site'), verbose_name='Site')
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack')
position = tables.Column(verbose_name='Position')
device_role = tables.Column(verbose_name='Role')

View File

@@ -17,6 +17,7 @@ class SiteTest(APITestCase):
'id',
'name',
'slug',
'region',
'tenant',
'facility',
'asn',
@@ -65,7 +66,7 @@ class SiteTest(APITestCase):
def test_get_list(self, endpoint='/{}api/dcim/sites/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
@@ -75,7 +76,7 @@ class SiteTest(APITestCase):
def test_get_detail(self, endpoint='/{}api/dcim/sites/1/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
@@ -84,9 +85,9 @@ class SiteTest(APITestCase):
def test_get_site_list_rack(self, endpoint='/{}api/dcim/sites/1/racks/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in json.loads(response.content):
for i in json.loads(response.content.decode('utf-8')):
self.assertEqual(
sorted(i.keys()),
sorted(self.rack_fields),
@@ -99,9 +100,9 @@ class SiteTest(APITestCase):
def test_get_site_list_graphs(self, endpoint='/{}api/dcim/sites/1/graphs/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in json.loads(response.content):
for i in json.loads(response.content.decode('utf-8')):
self.assertEqual(
sorted(i.keys()),
sorted(self.graph_fields),
@@ -151,6 +152,7 @@ class RackTest(APITestCase):
'width',
'u_height',
'desc_units',
'reservations',
'comments',
'custom_fields',
'front_units',
@@ -159,7 +161,7 @@ class RackTest(APITestCase):
def test_get_list(self, endpoint='/{}api/dcim/racks/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
@@ -173,7 +175,7 @@ class RackTest(APITestCase):
def test_get_detail(self, endpoint='/{}api/dcim/racks/1/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
@@ -202,7 +204,7 @@ class ManufacturersTest(APITestCase):
def test_get_list(self, endpoint='/{}api/dcim/manufacturers/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
@@ -212,7 +214,7 @@ class ManufacturersTest(APITestCase):
def test_get_detail(self, endpoint='/{}api/dcim/manufacturers/1/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
@@ -232,12 +234,14 @@ class DeviceTypeTest(APITestCase):
'part_number',
'u_height',
'is_full_depth',
'interface_ordering',
'is_console_server',
'is_pdu',
'is_network_device',
'subdevice_role',
'comments',
'custom_fields',
'instance_count',
]
nested_fields = [
@@ -249,7 +253,7 @@ class DeviceTypeTest(APITestCase):
def test_get_list(self, endpoint='/{}api/dcim/device-types/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
@@ -260,7 +264,7 @@ class DeviceTypeTest(APITestCase):
def test_detail_list(self, endpoint='/{}api/dcim/device-types/1/'.format(settings.BASE_PATH)):
# TODO: details returns list view.
# response = self.client.get(endpoint)
# content = json.loads(response.content)
# content = json.loads(response.content.decode('utf-8'))
# self.assertEqual(response.status_code, status.HTTP_200_OK)
# self.assertEqual(
# sorted(content.keys()),
@@ -283,7 +287,7 @@ class DeviceRolesTest(APITestCase):
def test_get_list(self, endpoint='/{}api/dcim/device-roles/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
@@ -293,7 +297,7 @@ class DeviceRolesTest(APITestCase):
def test_get_detail(self, endpoint='/{}api/dcim/device-roles/1/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
@@ -311,7 +315,7 @@ class PlatformsTest(APITestCase):
def test_get_list(self, endpoint='/{}api/dcim/platforms/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
@@ -321,7 +325,7 @@ class PlatformsTest(APITestCase):
def test_get_detail(self, endpoint='/{}api/dcim/platforms/1/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
@@ -343,6 +347,7 @@ class DeviceTest(APITestCase):
'platform',
'serial',
'asset_tag',
'site',
'rack',
'position',
'face',
@@ -359,7 +364,7 @@ class DeviceTest(APITestCase):
def test_get_list(self, endpoint='/{}api/dcim/devices/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
for device in content:
self.assertEqual(
@@ -414,6 +419,9 @@ class DeviceTest(APITestCase):
'primary_ip4_family',
'primary_ip4_id',
'primary_ip6',
'site_id',
'site_name',
'site_slug',
'rack_display_name',
'rack_facility_id',
'rack_id',
@@ -424,7 +432,7 @@ class DeviceTest(APITestCase):
]
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
device = content[0]
self.assertEqual(
@@ -434,7 +442,7 @@ class DeviceTest(APITestCase):
def test_get_detail(self, endpoint='/{}api/dcim/devices/1/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
@@ -452,7 +460,7 @@ class ConsoleServerPortsTest(APITestCase):
def test_get_list(self, endpoint='/{}api/dcim/devices/9/console-server-ports/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
for console_port in content:
self.assertEqual(
@@ -474,7 +482,7 @@ class ConsolePortsTest(APITestCase):
def test_get_list(self, endpoint='/{}api/dcim/devices/1/console-ports/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
for console_port in content:
self.assertEqual(
@@ -492,7 +500,7 @@ class ConsolePortsTest(APITestCase):
def test_get_detail(self, endpoint='/{}api/dcim/console-ports/1/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
@@ -513,7 +521,7 @@ class PowerPortsTest(APITestCase):
def test_get_list(self, endpoint='/{}api/dcim/devices/1/power-ports/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
@@ -527,7 +535,7 @@ class PowerPortsTest(APITestCase):
def test_get_detail(self, endpoint='/{}api/dcim/power-ports/1/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
@@ -548,7 +556,7 @@ class PowerOutletsTest(APITestCase):
def test_get_list(self, endpoint='/{}api/dcim/devices/11/power-outlets/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
@@ -569,6 +577,7 @@ class InterfaceTest(APITestCase):
'device',
'name',
'form_factor',
'lag',
'mac_address',
'mgmt_only',
'description',
@@ -582,6 +591,7 @@ class InterfaceTest(APITestCase):
'device',
'name',
'form_factor',
'lag',
'mac_address',
'mgmt_only',
'description',
@@ -598,7 +608,7 @@ class InterfaceTest(APITestCase):
def test_get_list(self, endpoint='/{}api/dcim/devices/1/interfaces/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
@@ -612,7 +622,7 @@ class InterfaceTest(APITestCase):
def test_get_detail(self, endpoint='/{}api/dcim/interfaces/1/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
@@ -624,19 +634,19 @@ class InterfaceTest(APITestCase):
)
def test_get_graph_list(self, endpoint='/{}api/dcim/interfaces/1/graphs/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
sorted(i.keys()),
sorted(SiteTest.graph_fields),
)
response = self.client.get(endpoint)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
sorted(i.keys()),
sorted(SiteTest.graph_fields),
)
def test_get_interface_connections(self, endpoint='/{}api/dcim/interface-connections/4/'
.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
@@ -658,7 +668,7 @@ class RelatedConnectionsTest(APITestCase):
def test_get_list(self, endpoint=('/{}api/dcim/related-connections/?peer-device=test1-edge1&peer-interface=xe-0/0/3'
.format(settings.BASE_PATH))):
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),

View File

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

View File

@@ -8,6 +8,12 @@ from . import views
urlpatterns = [
# Regions
url(r'^regions/$', views.RegionListView.as_view(), name='region_list'),
url(r'^regions/add/$', views.RegionEditView.as_view(), name='region_add'),
url(r'^regions/delete/$', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'),
url(r'^regions/(?P<pk>\d+)/edit/$', views.RegionEditView.as_view(), name='region_edit'),
# Sites
url(r'^sites/$', views.SiteListView.as_view(), name='site_list'),
url(r'^sites/add/$', views.SiteEditView.as_view(), name='site_add'),
@@ -29,6 +35,10 @@ urlpatterns = [
url(r'^rack-roles/delete/$', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'),
url(r'^rack-roles/(?P<pk>\d+)/edit/$', views.RackRoleEditView.as_view(), name='rackrole_edit'),
# Rack reservations
url(r'^rack-reservations/(?P<pk>\d+)/edit/$', views.RackReservationEditView.as_view(), name='rackreservation_edit'),
url(r'^rack-reservations/(?P<pk>\d+)/delete/$', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'),
# Racks
url(r'^racks/$', views.RackListView.as_view(), name='rack_list'),
url(r'^racks/add/$', views.RackEditView.as_view(), name='rack_add'),
@@ -38,6 +48,7 @@ urlpatterns = [
url(r'^racks/(?P<pk>\d+)/$', views.rack, name='rack'),
url(r'^racks/(?P<pk>\d+)/edit/$', views.RackEditView.as_view(), name='rack_edit'),
url(r'^racks/(?P<pk>\d+)/delete/$', views.RackDeleteView.as_view(), name='rack_delete'),
url(r'^racks/(?P<rack>\d+)/reservations/add/$', views.RackReservationEditView.as_view(), name='rack_add_reservation'),
# Manufacturers
url(r'^manufacturers/$', views.ManufacturerListView.as_view(), name='manufacturer_list'),

View File

@@ -26,7 +26,7 @@ from .models import (
CONNECTION_STATUS_CONNECTED, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device,
DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate,
Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
RackRole, Site,
RackReservation, RackRole, Region, Site,
)
@@ -66,19 +66,20 @@ class ComponentCreateView(View):
def get(self, request, pk):
parent = get_object_or_404(self.parent_model, pk=pk)
form = self.form(parent, initial=request.GET)
return render(request, 'dcim/device_component_add.html', {
'parent': parent,
'component_type': self.model._meta.verbose_name,
'form': self.form(initial=request.GET),
'cancel_url': parent.get_absolute_url(),
'form': form,
'return_url': parent.get_absolute_url(),
})
def post(self, request, pk):
parent = get_object_or_404(self.parent_model, pk=pk)
form = self.form(request.POST)
form = self.form(parent, request.POST)
if form.is_valid():
new_components = []
@@ -112,26 +113,62 @@ class ComponentCreateView(View):
'parent': parent,
'component_type': self.model._meta.verbose_name,
'form': form,
'cancel_url': parent.get_absolute_url(),
'return_url': parent.get_absolute_url(),
})
class ComponentEditView(ObjectEditView):
def get_return_url(self, obj):
return obj.device.get_absolute_url()
class ComponentDeleteView(ObjectDeleteView):
def get_return_url(self, obj):
return obj.device.get_absolute_url()
#
# Regions
#
class RegionListView(ObjectListView):
queryset = Region.objects.annotate(site_count=Count('sites'))
table = tables.RegionTable
template_name = 'dcim/region_list.html'
class RegionEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_region'
model = Region
form_class = forms.RegionForm
def get_return_url(self, obj):
return reverse('dcim:region_list')
class RegionBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_region'
cls = Region
default_return_url = 'dcim:region_list'
#
# Sites
#
class SiteListView(ObjectListView):
queryset = Site.objects.select_related('tenant')
queryset = Site.objects.select_related('region', 'tenant')
filter = filters.SiteFilter
filter_form = forms.SiteFilterForm
table = tables.SiteTable
edit_permissions = ['dcim.change_rack', 'dcim.delete_rack']
template_name = 'dcim/site_list.html'
def site(request, slug):
site = get_object_or_404(Site, slug=slug)
site = get_object_or_404(Site.objects.select_related('region', 'tenant__group'), slug=slug)
stats = {
'rack_count': Rack.objects.filter(site=site).count(),
'device_count': Device.objects.filter(rack__site=site).count(),
@@ -157,13 +194,13 @@ class SiteEditView(PermissionRequiredMixin, ObjectEditView):
model = Site
form_class = forms.SiteForm
template_name = 'dcim/site_edit.html'
obj_list_url = 'dcim:site_list'
default_return_url = 'dcim:site_list'
class SiteDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_site'
model = Site
redirect_url = 'dcim:site_list'
default_return_url = 'dcim:site_list'
class SiteBulkImportView(PermissionRequiredMixin, BulkImportView):
@@ -171,15 +208,16 @@ class SiteBulkImportView(PermissionRequiredMixin, BulkImportView):
form = forms.SiteImportForm
table = tables.SiteTable
template_name = 'dcim/site_import.html'
obj_list_url = 'dcim:site_list'
default_return_url = 'dcim:site_list'
class SiteBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_site'
cls = Site
filter = filters.SiteFilter
form = forms.SiteBulkEditForm
template_name = 'dcim/site_bulk_edit.html'
default_redirect_url = 'dcim:site_list'
default_return_url = 'dcim:site_list'
#
@@ -191,7 +229,6 @@ class RackGroupListView(ObjectListView):
filter = filters.RackGroupFilter
filter_form = forms.RackGroupFilterForm
table = tables.RackGroupTable
edit_permissions = ['dcim.change_rackgroup', 'dcim.delete_rackgroup']
template_name = 'dcim/rackgroup_list.html'
@@ -199,14 +236,16 @@ class RackGroupEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_rackgroup'
model = RackGroup
form_class = forms.RackGroupForm
obj_list_url = 'dcim:rackgroup_list'
use_obj_view = False
def get_return_url(self, obj):
return reverse('dcim:rackgroup_list')
class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_rackgroup'
cls = RackGroup
default_redirect_url = 'dcim:rackgroup_list'
filter = filters.RackGroupFilter
default_return_url = 'dcim:rackgroup_list'
#
@@ -216,7 +255,6 @@ class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class RackRoleListView(ObjectListView):
queryset = RackRole.objects.annotate(rack_count=Count('racks'))
table = tables.RackRoleTable
edit_permissions = ['dcim.change_rackrole', 'dcim.delete_rackrole']
template_name = 'dcim/rackrole_list.html'
@@ -224,14 +262,15 @@ class RackRoleEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_rackrole'
model = RackRole
form_class = forms.RackRoleForm
obj_list_url = 'dcim:rackrole_list'
use_obj_view = False
def get_return_url(self, obj):
return reverse('dcim:rackrole_list')
class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_rackrole'
cls = RackRole
default_redirect_url = 'dcim:rackrole_list'
default_return_url = 'dcim:rackrole_list'
#
@@ -244,21 +283,28 @@ class RackListView(ObjectListView):
filter = filters.RackFilter
filter_form = forms.RackFilterForm
table = tables.RackTable
edit_permissions = ['dcim.change_rack', 'dcim.delete_rack']
template_name = 'dcim/rack_list.html'
def rack(request, pk):
rack = get_object_or_404(Rack, pk=pk)
rack = get_object_or_404(Rack.objects.select_related('site__region', 'tenant__group', 'group', 'role'), pk=pk)
nonracked_devices = Device.objects.filter(rack=rack, position__isnull=True, parent_bay__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()
reservations = RackReservation.objects.filter(rack=rack)
reserved_units = {}
for r in reservations:
for u in r.units:
reserved_units[u] = r
return render(request, 'dcim/rack.html', {
'rack': rack,
'reservations': reservations,
'reserved_units': reserved_units,
'nonracked_devices': nonracked_devices,
'next_rack': next_rack,
'prev_rack': prev_rack,
@@ -272,13 +318,13 @@ class RackEditView(PermissionRequiredMixin, ObjectEditView):
model = Rack
form_class = forms.RackForm
template_name = 'dcim/rack_edit.html'
obj_list_url = 'dcim:rack_list'
default_return_url = 'dcim:rack_list'
class RackDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_rack'
model = Rack
redirect_url = 'dcim:rack_list'
default_return_url = 'dcim:rack_list'
class RackBulkImportView(PermissionRequiredMixin, BulkImportView):
@@ -286,21 +332,50 @@ class RackBulkImportView(PermissionRequiredMixin, BulkImportView):
form = forms.RackImportForm
table = tables.RackImportTable
template_name = 'dcim/rack_import.html'
obj_list_url = 'dcim:rack_list'
default_return_url = 'dcim:rack_list'
class RackBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_rack'
cls = Rack
filter = filters.RackFilter
form = forms.RackBulkEditForm
template_name = 'dcim/rack_bulk_edit.html'
default_redirect_url = 'dcim:rack_list'
default_return_url = 'dcim:rack_list'
class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_rack'
cls = Rack
default_redirect_url = 'dcim:rack_list'
filter = filters.RackFilter
default_return_url = 'dcim:rack_list'
#
# Rack reservations
#
class RackReservationEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_rackreservation'
model = RackReservation
form_class = forms.RackReservationForm
def alter_obj(self, obj, request, args, kwargs):
if not obj.pk:
obj.rack = get_object_or_404(Rack, pk=kwargs['rack'])
obj.user = request.user
return obj
def get_return_url(self, obj):
return obj.rack.get_absolute_url()
class RackReservationDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_rackreservation'
model = RackReservation
def get_return_url(self, obj):
return obj.rack.get_absolute_url()
#
@@ -310,7 +385,6 @@ class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class ManufacturerListView(ObjectListView):
queryset = Manufacturer.objects.annotate(devicetype_count=Count('device_types'))
table = tables.ManufacturerTable
edit_permissions = ['dcim.change_manufacturer', 'dcim.delete_manufacturer']
template_name = 'dcim/manufacturer_list.html'
@@ -318,14 +392,15 @@ class ManufacturerEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_manufacturer'
model = Manufacturer
form_class = forms.ManufacturerForm
obj_list_url = 'dcim:manufacturer_list'
use_obj_view = False
def get_return_url(self, obj):
return reverse('dcim:manufacturer_list')
class ManufacturerBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_manufacturer'
cls = Manufacturer
default_redirect_url = 'dcim:manufacturer_list'
default_return_url = 'dcim:manufacturer_list'
#
@@ -337,7 +412,6 @@ class DeviceTypeListView(ObjectListView):
filter = filters.DeviceTypeFilter
filter_form = forms.DeviceTypeFilterForm
table = tables.DeviceTypeTable
edit_permissions = ['dcim.change_devicetype', 'dcim.delete_devicetype']
template_name = 'dcim/devicetype_list.html'
@@ -358,10 +432,14 @@ def devicetype(request, pk):
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))
mgmt_interface_table = tables.InterfaceTemplateTable(
list(InterfaceTemplate.objects.order_naturally(devicetype.interface_ordering).filter(device_type=devicetype,
mgmt_only=True))
)
interface_table = tables.InterfaceTemplateTable(
list(InterfaceTemplate.objects.order_naturally(devicetype.interface_ordering).filter(device_type=devicetype,
mgmt_only=False))
)
devicebay_table = tables.DeviceBayTemplateTable(
natsorted(DeviceBayTemplate.objects.filter(device_type=devicetype), key=attrgetter('name'))
)
@@ -391,27 +469,29 @@ class DeviceTypeEditView(PermissionRequiredMixin, ObjectEditView):
model = DeviceType
form_class = forms.DeviceTypeForm
template_name = 'dcim/devicetype_edit.html'
obj_list_url = 'dcim:devicetype_list'
default_return_url = 'dcim:devicetype_list'
class DeviceTypeDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_devicetype'
model = DeviceType
redirect_url = 'dcim:devicetype_list'
default_return_url = 'dcim:devicetype_list'
class DeviceTypeBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_devicetype'
cls = DeviceType
filter = filters.DeviceTypeFilter
form = forms.DeviceTypeBulkEditForm
template_name = 'dcim/devicetype_bulk_edit.html'
default_redirect_url = 'dcim:devicetype_list'
default_return_url = 'dcim:devicetype_list'
class DeviceTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_devicetype'
cls = DeviceType
default_redirect_url = 'dcim:devicetype_list'
filter = filters.DeviceTypeFilter
default_return_url = 'dcim:devicetype_list'
#
@@ -525,7 +605,6 @@ class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class DeviceRoleListView(ObjectListView):
queryset = DeviceRole.objects.annotate(device_count=Count('devices'))
table = tables.DeviceRoleTable
edit_permissions = ['dcim.change_devicerole', 'dcim.delete_devicerole']
template_name = 'dcim/devicerole_list.html'
@@ -533,14 +612,15 @@ class DeviceRoleEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_devicerole'
model = DeviceRole
form_class = forms.DeviceRoleForm
obj_list_url = 'dcim:devicerole_list'
use_obj_view = False
def get_return_url(self, obj):
return reverse('dcim:devicerole_list')
class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_devicerole'
cls = DeviceRole
default_redirect_url = 'dcim:devicerole_list'
default_return_url = 'dcim:devicerole_list'
#
@@ -550,7 +630,6 @@ class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class PlatformListView(ObjectListView):
queryset = Platform.objects.annotate(device_count=Count('devices'))
table = tables.PlatformTable
edit_permissions = ['dcim.change_platform', 'dcim.delete_platform']
template_name = 'dcim/platform_list.html'
@@ -558,14 +637,15 @@ class PlatformEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_platform'
model = Platform
form_class = forms.PlatformForm
obj_list_url = 'dcim:platform_list'
use_obj_view = False
def get_return_url(self, obj):
return reverse('dcim:platform_list')
class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_platform'
cls = Platform
default_redirect_url = 'dcim:platform_list'
default_return_url = 'dcim:platform_list'
#
@@ -573,18 +653,19 @@ class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
#
class DeviceListView(ObjectListView):
queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'tenant', 'rack__site',
queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack',
'primary_ip4', 'primary_ip6')
filter = filters.DeviceFilter
filter_form = forms.DeviceFilterForm
table = tables.DeviceTable
edit_permissions = ['dcim.change_device', 'dcim.delete_device']
template_name = 'dcim/device_list.html'
def device(request, pk):
device = get_object_or_404(Device, pk=pk)
device = get_object_or_404(Device.objects.select_related(
'site__region', 'rack__group', 'tenant__group', 'device_role', 'platform'
), pk=pk)
console_ports = natsorted(
ConsolePort.objects.filter(device=device).select_related('cs_port__device'), key=attrgetter('name')
)
@@ -597,16 +678,14 @@ def device(request, pk):
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__interface_b__device',
'connected_as_b__interface_a__device',
'circuit_termination__circuit',
)
mgmt_interfaces = Interface.objects.filter(device=device, mgmt_only=True).select_related(
'connected_as_a__interface_b__device',
'connected_as_b__interface_a__device',
'circuit_termination__circuit',
)
interfaces = Interface.objects.order_naturally(device.device_type.interface_ordering)\
.filter(device=device, mgmt_only=False)\
.select_related('connected_as_a__interface_b__device', 'connected_as_b__interface_a__device',
'circuit_termination__circuit')
mgmt_interfaces = Interface.objects.order_naturally(device.device_type.interface_ordering)\
.filter(device=device, mgmt_only=True)\
.select_related('connected_as_a__interface_b__device', 'connected_as_b__interface_a__device',
'circuit_termination__circuit')
device_bays = natsorted(
DeviceBay.objects.filter(device=device).select_related('installed_device__device_type__manufacturer'),
key=attrgetter('name')
@@ -659,13 +738,13 @@ class DeviceEditView(PermissionRequiredMixin, ObjectEditView):
form_class = forms.DeviceForm
fields_initial = ['site', 'rack', 'position', 'face', 'device_bay']
template_name = 'dcim/device_edit.html'
obj_list_url = 'dcim:device_list'
default_return_url = 'dcim:device_list'
class DeviceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_device'
model = Device
redirect_url = 'dcim:device_list'
default_return_url = 'dcim:device_list'
class DeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
@@ -673,7 +752,7 @@ class DeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
form = forms.DeviceImportForm
table = tables.DeviceImportTable
template_name = 'dcim/device_import.html'
obj_list_url = 'dcim:device_list'
default_return_url = 'dcim:device_list'
class ChildDeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
@@ -681,7 +760,7 @@ class ChildDeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
form = forms.ChildDeviceImportForm
table = tables.DeviceImportTable
template_name = 'dcim/device_import_child.html'
obj_list_url = 'dcim:device_list'
default_return_url = 'dcim:device_list'
def save_obj(self, obj):
# Inherent rack from parent device
@@ -696,15 +775,17 @@ class ChildDeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
class DeviceBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_device'
cls = Device
filter = filters.DeviceFilter
form = forms.DeviceBulkEditForm
template_name = 'dcim/device_bulk_edit.html'
default_redirect_url = 'dcim:device_list'
default_return_url = 'dcim:device_list'
class DeviceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_device'
cls = Device
default_redirect_url = 'dcim:device_list'
filter = filters.DeviceFilter
default_return_url = 'dcim:device_list'
def device_inventory(request, pk):
@@ -722,7 +803,8 @@ def device_inventory(request, pk):
def device_lldp_neighbors(request, pk):
device = get_object_or_404(Device, pk=pk)
interfaces = Interface.objects.filter(device=device).select_related('connected_as_a', 'connected_as_b')
interfaces = Interface.objects.order_naturally(device.device_type.interface_ordering).filter(device=device)\
.select_related('connected_as_a', 'connected_as_b')
return render(request, 'dcim/device_lldp_neighbors.html', {
'device': device,
@@ -769,7 +851,7 @@ def consoleport_connect(request, pk):
return render(request, 'dcim/consoleport_connect.html', {
'consoleport': consoleport,
'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': consoleport.device.pk}),
'return_url': reverse('dcim:device', kwargs={'pk': consoleport.device.pk}),
})
@@ -798,17 +880,17 @@ def consoleport_disconnect(request, pk):
return render(request, 'dcim/consoleport_disconnect.html', {
'consoleport': consoleport,
'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': consoleport.device.pk}),
'return_url': reverse('dcim:device', kwargs={'pk': consoleport.device.pk}),
})
class ConsolePortEditView(PermissionRequiredMixin, ObjectEditView):
class ConsolePortEditView(PermissionRequiredMixin, ComponentEditView):
permission_required = 'dcim.change_consoleport'
model = ConsolePort
form_class = forms.ConsolePortForm
class ConsolePortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class ConsolePortDeleteView(PermissionRequiredMixin, ComponentDeleteView):
permission_required = 'dcim.delete_consoleport'
model = ConsolePort
@@ -865,7 +947,7 @@ def consoleserverport_connect(request, pk):
return render(request, 'dcim/consoleserverport_connect.html', {
'consoleserverport': consoleserverport,
'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': consoleserverport.device.pk}),
'return_url': reverse('dcim:device', kwargs={'pk': consoleserverport.device.pk}),
})
@@ -895,17 +977,17 @@ def consoleserverport_disconnect(request, pk):
return render(request, 'dcim/consoleserverport_disconnect.html', {
'consoleserverport': consoleserverport,
'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': consoleserverport.device.pk}),
'return_url': reverse('dcim:device', kwargs={'pk': consoleserverport.device.pk}),
})
class ConsoleServerPortEditView(PermissionRequiredMixin, ObjectEditView):
class ConsoleServerPortEditView(PermissionRequiredMixin, ComponentEditView):
permission_required = 'dcim.change_consoleserverport'
model = ConsoleServerPort
form_class = forms.ConsoleServerPortForm
class ConsoleServerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class ConsoleServerPortDeleteView(PermissionRequiredMixin, ComponentDeleteView):
permission_required = 'dcim.delete_consoleserverport'
model = ConsoleServerPort
@@ -955,7 +1037,7 @@ def powerport_connect(request, pk):
return render(request, 'dcim/powerport_connect.html', {
'powerport': powerport,
'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': powerport.device.pk}),
'return_url': reverse('dcim:device', kwargs={'pk': powerport.device.pk}),
})
@@ -984,17 +1066,17 @@ def powerport_disconnect(request, pk):
return render(request, 'dcim/powerport_disconnect.html', {
'powerport': powerport,
'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': powerport.device.pk}),
'return_url': reverse('dcim:device', kwargs={'pk': powerport.device.pk}),
})
class PowerPortEditView(PermissionRequiredMixin, ObjectEditView):
class PowerPortEditView(PermissionRequiredMixin, ComponentEditView):
permission_required = 'dcim.change_powerport'
model = PowerPort
form_class = forms.PowerPortForm
class PowerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class PowerPortDeleteView(PermissionRequiredMixin, ComponentDeleteView):
permission_required = 'dcim.delete_powerport'
model = PowerPort
@@ -1051,7 +1133,7 @@ def poweroutlet_connect(request, pk):
return render(request, 'dcim/poweroutlet_connect.html', {
'poweroutlet': poweroutlet,
'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': poweroutlet.device.pk}),
'return_url': reverse('dcim:device', kwargs={'pk': poweroutlet.device.pk}),
})
@@ -1080,17 +1162,17 @@ def poweroutlet_disconnect(request, pk):
return render(request, 'dcim/poweroutlet_disconnect.html', {
'poweroutlet': poweroutlet,
'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': poweroutlet.device.pk}),
'return_url': reverse('dcim:device', kwargs={'pk': poweroutlet.device.pk}),
})
class PowerOutletEditView(PermissionRequiredMixin, ObjectEditView):
class PowerOutletEditView(PermissionRequiredMixin, ComponentEditView):
permission_required = 'dcim.change_poweroutlet'
model = PowerOutlet
form_class = forms.PowerOutletForm
class PowerOutletDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class PowerOutletDeleteView(PermissionRequiredMixin, ComponentDeleteView):
permission_required = 'dcim.delete_poweroutlet'
model = PowerOutlet
@@ -1114,13 +1196,13 @@ class InterfaceAddView(PermissionRequiredMixin, ComponentCreateView):
model_form = forms.InterfaceForm
class InterfaceEditView(PermissionRequiredMixin, ObjectEditView):
class InterfaceEditView(PermissionRequiredMixin, ComponentEditView):
permission_required = 'dcim.change_interface'
model = Interface
form_class = forms.InterfaceForm
class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class InterfaceDeleteView(PermissionRequiredMixin, ComponentDeleteView):
permission_required = 'dcim.delete_interface'
model = Interface
@@ -1152,13 +1234,13 @@ class DeviceBayAddView(PermissionRequiredMixin, ComponentCreateView):
model_form = forms.DeviceBayForm
class DeviceBayEditView(PermissionRequiredMixin, ObjectEditView):
class DeviceBayEditView(PermissionRequiredMixin, ComponentEditView):
permission_required = 'dcim.change_devicebay'
model = DeviceBay
form_class = forms.DeviceBayForm
class DeviceBayDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class DeviceBayDeleteView(PermissionRequiredMixin, ComponentDeleteView):
permission_required = 'dcim.delete_devicebay'
model = DeviceBay
@@ -1185,7 +1267,7 @@ def devicebay_populate(request, pk):
return render(request, 'dcim/devicebay_populate.html', {
'device_bay': device_bay,
'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': device_bay.device.pk}),
'return_url': reverse('dcim:device', kwargs={'pk': device_bay.device.pk}),
})
@@ -1209,7 +1291,7 @@ def devicebay_depopulate(request, pk):
return render(request, 'dcim/devicebay_depopulate.html', {
'device_bay': device_bay,
'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': device_bay.device.pk}),
'return_url': reverse('dcim:device', kwargs={'pk': device_bay.device.pk}),
})
@@ -1238,7 +1320,7 @@ class DeviceBulkAddComponentView(View):
# Are we editing *all* objects in the queryset or just a selected subset?
if request.POST.get('_all'):
pk_list = [int(pk) for pk in request.POST.get('pk_all').split(',') if pk]
pk_list = [obj.pk for obj in filters.DeviceFilter(request.GET, Device.objects.all())]
else:
pk_list = [int(pk) for pk in request.POST.getlist('pk')]
@@ -1284,7 +1366,7 @@ class DeviceBulkAddComponentView(View):
'form': form,
'component_name': self.model._meta.verbose_name_plural,
'selected_devices': selected_devices,
'cancel_url': reverse('dcim:device_list'),
'return_url': reverse('dcim:device_list'),
})
@@ -1357,7 +1439,7 @@ def interfaceconnection_add(request, pk):
else:
form = forms.InterfaceConnectionForm(device, initial={
'interface_a': request.GET.get('interface_a', None),
'site_b': request.GET.get('site_b', device.rack.site),
'site_b': request.GET.get('site_b', device.site),
'rack_b': request.GET.get('rack_b', None),
'device_b': request.GET.get('device_b', None),
'interface_b': request.GET.get('interface_b', None),
@@ -1366,7 +1448,7 @@ def interfaceconnection_add(request, pk):
return render(request, 'dcim/interfaceconnection_edit.html', {
'device': device,
'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}),
'return_url': reverse('dcim:device', kwargs={'pk': device.pk}),
})
@@ -1398,15 +1480,15 @@ def interfaceconnection_delete(request, pk):
# Determine where to direct user upon cancellation
if device_id:
cancel_url = reverse('dcim:device', kwargs={'pk': device_id})
return_url = reverse('dcim:device', kwargs={'pk': device_id})
else:
cancel_url = reverse('dcim:device_list')
return_url = reverse('dcim:device_list')
return render(request, 'dcim/interfaceconnection_delete.html', {
'interfaceconnection': interfaceconnection,
'device_id': device_id,
'form': form,
'cancel_url': cancel_url,
'return_url': return_url,
})
@@ -1485,7 +1567,7 @@ def ipaddress_assign(request, pk):
return render(request, 'dcim/ipaddress_assign.html', {
'device': device,
'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}),
'return_url': reverse('dcim:device', kwargs={'pk': device.pk}),
})
@@ -1493,18 +1575,17 @@ def ipaddress_assign(request, pk):
# Modules
#
class ModuleEditView(PermissionRequiredMixin, ObjectEditView):
class ModuleEditView(PermissionRequiredMixin, ComponentEditView):
permission_required = 'dcim.change_module'
model = Module
form_class = forms.ModuleForm
def alter_obj(self, obj, args, kwargs):
if 'device' in kwargs:
device = get_object_or_404(Device, pk=kwargs['device'])
obj.device = device
def alter_obj(self, obj, request, url_args, url_kwargs):
if 'device' in url_kwargs:
obj.device = get_object_or_404(Device, pk=url_kwargs['device'])
return obj
class ModuleDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class ModuleDeleteView(PermissionRequiredMixin, ComponentDeleteView):
permission_required = 'dcim.delete_module'
model = Module

View File

@@ -50,7 +50,7 @@ class FlatJSONRenderer(renderers.BaseRenderer):
def render(self, data, media_type=None, renderer_context=None):
def flatten(entry):
for key, val in entry.iteritems():
for key, val in entry.items():
if isinstance(val, dict):
for child_key, child_val in flatten(val):
yield "{}_{}".format(key, child_key), child_val

View File

@@ -8,6 +8,7 @@ from django.core.validators import ValidationError
from django.db import models
from django.http import HttpResponse
from django.template import Template, Context
from django.utils.encoding import python_2_unicode_compatible
from django.utils.safestring import mark_safe
@@ -93,6 +94,7 @@ class CustomFieldModel(object):
return OrderedDict([(field, None) for field in fields])
@python_2_unicode_compatible
class CustomField(models.Model):
obj_type = models.ManyToManyField(ContentType, related_name='custom_fields', verbose_name='Object(s)',
limit_choices_to={'model__in': CUSTOMFIELD_MODELS},
@@ -114,7 +116,7 @@ class CustomField(models.Model):
class Meta:
ordering = ['weight', 'name']
def __unicode__(self):
def __str__(self):
return self.label or self.name.replace('_', ' ').capitalize()
def serialize_value(self, value):
@@ -153,6 +155,7 @@ class CustomField(models.Model):
return serialized_value
@python_2_unicode_compatible
class CustomFieldValue(models.Model):
field = models.ForeignKey('CustomField', related_name='values')
obj_type = models.ForeignKey(ContentType, related_name='+', on_delete=models.PROTECT)
@@ -164,7 +167,7 @@ class CustomFieldValue(models.Model):
ordering = ['obj_type', 'obj_id']
unique_together = ['field', 'obj_type', 'obj_id']
def __unicode__(self):
def __str__(self):
return u'{} {}'.format(self.obj, self.field)
@property
@@ -183,6 +186,7 @@ class CustomFieldValue(models.Model):
super(CustomFieldValue, self).save(*args, **kwargs)
@python_2_unicode_compatible
class CustomFieldChoice(models.Model):
field = models.ForeignKey('CustomField', related_name='choices', limit_choices_to={'type': CF_TYPE_SELECT},
on_delete=models.CASCADE)
@@ -193,7 +197,7 @@ class CustomFieldChoice(models.Model):
ordering = ['field', 'weight', 'value']
unique_together = ['field', 'value']
def __unicode__(self):
def __str__(self):
return self.value
def clean(self):
@@ -207,6 +211,7 @@ class CustomFieldChoice(models.Model):
CustomFieldValue.objects.filter(field__type=CF_TYPE_SELECT, serialized_value=str(pk)).delete()
@python_2_unicode_compatible
class Graph(models.Model):
type = models.PositiveSmallIntegerField(choices=GRAPH_TYPE_CHOICES)
weight = models.PositiveSmallIntegerField(default=1000)
@@ -217,7 +222,7 @@ class Graph(models.Model):
class Meta:
ordering = ['type', 'weight', 'name']
def __unicode__(self):
def __str__(self):
return self.name
def embed_url(self, obj):
@@ -231,6 +236,7 @@ class Graph(models.Model):
return template.render(Context({'obj': obj}))
@python_2_unicode_compatible
class ExportTemplate(models.Model):
content_type = models.ForeignKey(ContentType, limit_choices_to={'model__in': EXPORTTEMPLATE_MODELS})
name = models.CharField(max_length=100)
@@ -245,7 +251,7 @@ class ExportTemplate(models.Model):
['content_type', 'name']
]
def __unicode__(self):
def __str__(self):
return u'{}: {}'.format(self.content_type, self.name)
def to_response(self, context_dict, filename):
@@ -264,6 +270,7 @@ class ExportTemplate(models.Model):
return response
@python_2_unicode_compatible
class TopologyMap(models.Model):
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
@@ -278,7 +285,7 @@ class TopologyMap(models.Model):
class Meta:
ordering = ['name']
def __unicode__(self):
def __str__(self):
return self.name
@property
@@ -328,6 +335,7 @@ class UserActionManager(models.Manager):
self.log_bulk_action(user, content_type, ACTION_BULK_DELETE, message)
@python_2_unicode_compatible
class UserAction(models.Model):
"""
A record of an action (add, edit, or delete) performed on an object by a User.
@@ -344,7 +352,7 @@ class UserAction(models.Model):
class Meta:
ordering = ['-time']
def __unicode__(self):
def __str__(self):
if self.message:
return u'{} {}'.format(self.user, self.message)
return u'{} {} {}'.format(self.user, self.get_action_display(), self.content_type)

View File

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

View File

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

View File

@@ -13,15 +13,10 @@ from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLAN
class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
q = django_filters.CharFilter(
method='search',
label='Search',
)
name = django_filters.CharFilter(
name='name',
lookup_type='icontains',
label='Name',
)
tenant_id = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
@@ -34,7 +29,9 @@ class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet):
label='Tenant (slug)',
)
def search(self, queryset, value):
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(rd__icontains=value) |
@@ -43,7 +40,7 @@ class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet):
class Meta:
model = VRF
fields = ['rd']
fields = ['name', 'rd']
class RIRFilter(django_filters.FilterSet):
@@ -54,8 +51,8 @@ class RIRFilter(django_filters.FilterSet):
class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
q = django_filters.CharFilter(
method='search',
label='Search',
)
rir_id = django_filters.ModelMultipleChoiceFilter(
@@ -74,7 +71,9 @@ class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet):
model = Aggregate
fields = ['family', 'date_added']
def search(self, queryset, value):
def search(self, queryset, name, value):
if not value.strip():
return queryset
qs_filter = Q(description__icontains=value)
try:
prefix = str(IPNetwork(value.strip()).cidr)
@@ -85,14 +84,18 @@ class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet):
class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
q = django_filters.CharFilter(
method='search',
label='Search',
)
parent = django_filters.MethodFilter(
action='search_by_parent',
parent = django_filters.CharFilter(
method='search_by_parent',
label='Parent prefix',
)
mask_length = django_filters.NumberFilter(
method='filter_mask_length',
label='Mask length',
)
vrf_id = NullableModelMultipleChoiceFilter(
name='vrf_id',
queryset=VRF.objects.all(),
@@ -151,7 +154,9 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
model = Prefix
fields = ['family', 'status']
def search(self, queryset, value):
def search(self, queryset, name, value):
if not value.strip():
return queryset
qs_filter = Q(description__icontains=value)
try:
prefix = str(IPNetwork(value.strip()).cidr)
@@ -160,7 +165,7 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
pass
return queryset.filter(qs_filter)
def search_by_parent(self, queryset, value):
def search_by_parent(self, queryset, name, value):
value = value.strip()
if not value:
return queryset
@@ -170,34 +175,25 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
except AddrFormatError:
return queryset.none()
def _tenant(self, queryset, value):
if str(value) == '':
def filter_mask_length(self, queryset, name, value):
if not value:
return queryset
return queryset.filter(
Q(tenant__slug=value) |
Q(tenant__isnull=True, vrf__tenant__slug=value)
)
def _tenant_id(self, queryset, value):
try:
value = int(value)
except ValueError:
return queryset.none()
return queryset.filter(
Q(tenant__pk=value) |
Q(tenant__isnull=True, vrf__tenant__pk=value)
)
return queryset.filter(prefix__net_mask_length=value)
class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
q = django_filters.CharFilter(
method='search',
label='Search',
)
parent = django_filters.MethodFilter(
action='search_by_parent',
parent = django_filters.CharFilter(
method='search_by_parent',
label='Parent prefix',
)
mask_length = django_filters.NumberFilter(
method='filter_mask_length',
label='Mask length',
)
vrf_id = NullableModelMultipleChoiceFilter(
name='vrf_id',
queryset=VRF.objects.all(),
@@ -239,9 +235,11 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
class Meta:
model = IPAddress
fields = ['q', 'family', 'status']
fields = ['family', 'status']
def search(self, queryset, value):
def search(self, queryset, name, value):
if not value.strip():
return queryset
qs_filter = Q(description__icontains=value)
try:
ipaddress = str(IPNetwork(value.strip()))
@@ -250,25 +248,30 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
pass
return queryset.filter(qs_filter)
def search_by_parent(self, queryset, value):
def search_by_parent(self, queryset, name, value):
value = value.strip()
if not value:
return queryset
try:
query = str(IPNetwork(value).cidr)
query = str(IPNetwork(value.strip()).cidr)
return queryset.filter(address__net_contained_or_equal=query)
except AddrFormatError:
return queryset.none()
def filter_mask_length(self, queryset, name, value):
if not value:
return queryset
return queryset.filter(address__net_mask_length=value)
class VLANGroupFilter(django_filters.FilterSet):
site_id = django_filters.ModelMultipleChoiceFilter(
site_id = NullableModelMultipleChoiceFilter(
name='site',
queryset=Site.objects.all(),
label='Site (ID)',
)
site = django_filters.ModelMultipleChoiceFilter(
name='site__slug',
site = NullableModelMultipleChoiceFilter(
name='site',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site (slug)',
@@ -276,20 +279,21 @@ class VLANGroupFilter(django_filters.FilterSet):
class Meta:
model = VLANGroup
fields = ['name']
class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
q = django_filters.CharFilter(
method='search',
label='Search',
)
site_id = django_filters.ModelMultipleChoiceFilter(
site_id = NullableModelMultipleChoiceFilter(
name='site',
queryset=Site.objects.all(),
label='Site (ID)',
)
site = django_filters.ModelMultipleChoiceFilter(
name='site__slug',
site = NullableModelMultipleChoiceFilter(
name='site',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site (slug)',
@@ -305,15 +309,6 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug',
label='Group',
)
name = django_filters.CharFilter(
name='name',
lookup_type='icontains',
label='Name',
)
vid = django_filters.NumberFilter(
name='vid',
label='VLAN number (1-4095)',
)
tenant_id = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
@@ -339,12 +334,14 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
class Meta:
model = VLAN
fields = ['status']
fields = ['name', 'vid', 'status']
def search(self, queryset, value):
def search(self, queryset, name, value):
if not value.strip():
return queryset
qs_filter = Q(name__icontains=value) | Q(description__icontains=value)
try:
qs_filter |= Q(vid=int(value))
qs_filter |= Q(vid=int(value.strip()))
except ValueError:
pass
return queryset.filter(qs_filter)

View File

@@ -21,6 +21,12 @@ IP_FAMILY_CHOICES = [
(6, 'IPv6'),
]
PREFIX_MASK_LENGTH_CHOICES = [
('', '---------'),
] + [(i, i) for i in range(1, 128)]
IPADDRESS_MASK_LENGTH_CHOICES = PREFIX_MASK_LENGTH_CHOICES + [(128, 128)]
#
# VRFs
@@ -63,6 +69,7 @@ class VRFBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
class VRFFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = VRF
q = forms.CharField(required=False, label='Search')
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('vrfs')), to_field_name='slug',
null_option=(0, None))
@@ -128,9 +135,13 @@ class AggregateBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
class AggregateFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Aggregate
q = forms.CharField(required=False, label='Search')
family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family')
rir = FilterChoiceField(queryset=RIR.objects.annotate(filter_count=Count('aggregates')), to_field_name='slug',
label='RIR')
rir = FilterChoiceField(
queryset=RIR.objects.annotate(filter_count=Count('aggregates')),
to_field_name='slug',
label='RIR'
)
#
@@ -151,7 +162,7 @@ class RoleForm(BootstrapMixin, forms.ModelForm):
class PrefixForm(BootstrapMixin, CustomFieldForm):
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site',
widget=forms.Select(attrs={'filter-for': 'vlan'}))
widget=forms.Select(attrs={'filter-for': 'vlan', 'nullable': 'true'}))
vlan = forms.ModelChoiceField(queryset=VLAN.objects.all(), required=False, label='VLAN',
widget=APISelect(api_url='/api/ipam/vlans/?site_id={{site}}',
display_field='display_name'))
@@ -171,7 +182,7 @@ class PrefixForm(BootstrapMixin, CustomFieldForm):
elif self.initial.get('site'):
self.fields['vlan'].queryset = VLAN.objects.filter(site=self.initial['site'])
else:
self.fields['vlan'].choices = []
self.fields['vlan'].queryset = VLAN.objects.filter(site=None)
class PrefixFromCSVForm(forms.ModelForm):
@@ -215,6 +226,8 @@ class PrefixFromCSVForm(forms.ModelForm):
elif vlan_vid and site:
try:
self.instance.vlan = VLAN.objects.get(site=site, vid=vlan_vid)
except VLAN.DoesNotExist:
self.add_error('vlan_vid', "Invalid VLAN ID ({}) for site {}.".format(vlan_vid, site))
except VLAN.MultipleObjectsReturned:
self.add_error('vlan_vid', "Multiple VLANs found ({} - VID {})".format(site, vlan_vid))
elif vlan_vid:
@@ -254,19 +267,34 @@ def prefix_status_choices():
class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Prefix
parent = forms.CharField(required=False, label='Search Within', widget=forms.TextInput(attrs={
'placeholder': 'Network',
q = forms.CharField(required=False, label='Search')
parent = forms.CharField(required=False, label='Parent prefix', widget=forms.TextInput(attrs={
'placeholder': 'Prefix',
}))
family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family')
vrf = FilterChoiceField(queryset=VRF.objects.annotate(filter_count=Count('prefixes')), to_field_name='rd',
label='VRF', null_option=(0, 'Global'))
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('prefixes')), to_field_name='slug',
null_option=(0, 'None'))
family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address family')
mask_length = forms.ChoiceField(required=False, choices=PREFIX_MASK_LENGTH_CHOICES, label='Mask length')
vrf = FilterChoiceField(
queryset=VRF.objects.annotate(filter_count=Count('prefixes')),
to_field_name='rd',
label='VRF',
null_option=(0, 'Global')
)
tenant = FilterChoiceField(
queryset=Tenant.objects.annotate(filter_count=Count('prefixes')),
to_field_name='slug',
null_option=(0, 'None')
)
status = forms.MultipleChoiceField(choices=prefix_status_choices, required=False)
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('prefixes')), to_field_name='slug',
null_option=(0, 'None'))
role = FilterChoiceField(queryset=Role.objects.annotate(filter_count=Count('prefixes')), to_field_name='slug',
null_option=(0, 'None'))
site = FilterChoiceField(
queryset=Site.objects.annotate(filter_count=Count('prefixes')),
to_field_name='slug',
null_option=(0, 'None')
)
role = FilterChoiceField(
queryset=Role.objects.annotate(filter_count=Count('prefixes')),
to_field_name='slug',
null_option=(0, 'None')
)
expand = forms.BooleanField(required=False, label='Expand prefix hierarchy')
@@ -302,10 +330,10 @@ class IPAddressForm(BootstrapMixin, CustomFieldForm):
nat_inside = self.instance.nat_inside
# If the IP is assigned to an interface, populate site/device fields accordingly
if self.instance.nat_inside.interface:
self.initial['nat_site'] = self.instance.nat_inside.interface.device.rack.site.pk
self.initial['nat_site'] = self.instance.nat_inside.interface.device.site.pk
self.initial['nat_device'] = self.instance.nat_inside.interface.device.pk
self.fields['nat_device'].queryset = Device.objects.filter(
rack__site=nat_inside.interface.device.rack.site)
rack__site=nat_inside.interface.device.site)
self.fields['nat_inside'].queryset = IPAddress.objects.filter(
interface__device=nat_inside.interface.device)
else:
@@ -334,25 +362,61 @@ class IPAddressForm(BootstrapMixin, CustomFieldForm):
class IPAddressBulkAddForm(BootstrapMixin, forms.Form):
address = ExpandableIPAddressField()
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF')
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF', empty_label='Global')
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
status = forms.ChoiceField(choices=IPADDRESS_STATUS_CHOICES)
description = forms.CharField(max_length=100, required=False)
class IPAddressAssignForm(BootstrapMixin, forms.Form):
site = forms.ModelChoiceField(queryset=Site.objects.all(), label='Site', required=False,
widget=forms.Select(attrs={'filter-for': 'rack'}))
rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False,
widget=APISelect(api_url='/api/dcim/racks/?site_id={{site}}', display_field='display_name', attrs={'filter-for': 'device'}))
device = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False,
widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}', display_field='display_name', attrs={'filter-for': 'interface'}))
livesearch = forms.CharField(required=False, label='Device', widget=Livesearch(
query_key='q', query_url='dcim-api:device_list', field_to_update='device')
site = forms.ModelChoiceField(
queryset=Site.objects.all(),
label='Site',
required=False,
widget=forms.Select(
attrs={'filter-for': 'rack'}
)
)
rack = forms.ModelChoiceField(
queryset=Rack.objects.all(),
label='Rack',
required=False,
widget=APISelect(
api_url='/api/dcim/racks/?site_id={{site}}',
display_field='display_name',
attrs={'filter-for': 'device', 'nullable': 'true'}
)
)
device = forms.ModelChoiceField(
queryset=Device.objects.all(),
label='Device',
required=False,
widget=APISelect(
api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}',
display_field='display_name',
attrs={'filter-for': 'interface'}
)
)
livesearch = forms.CharField(
required=False,
label='Device',
widget=Livesearch(
query_key='q',
query_url='dcim-api:device_list',
field_to_update='device'
)
)
interface = forms.ModelChoiceField(
queryset=Interface.objects.all(),
label='Interface',
widget=APISelect(
api_url='/api/dcim/devices/{{device}}/interfaces/'
)
)
set_as_primary = forms.BooleanField(
label='Set as primary IP for device',
required=False
)
interface = forms.ModelChoiceField(queryset=Interface.objects.all(), label='Interface',
widget=APISelect(api_url='/api/dcim/devices/{{device}}/interfaces/'))
set_as_primary = forms.BooleanField(label='Set as primary IP for device', required=False)
def __init__(self, *args, **kwargs):
@@ -442,14 +506,23 @@ def ipaddress_status_choices():
class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = IPAddress
parent = forms.CharField(required=False, label='Search Within', widget=forms.TextInput(attrs={
q = forms.CharField(required=False, label='Search')
parent = forms.CharField(required=False, label='Parent Prefix', widget=forms.TextInput(attrs={
'placeholder': 'Prefix',
}))
family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family')
vrf = FilterChoiceField(queryset=VRF.objects.annotate(filter_count=Count('ip_addresses')), to_field_name='rd',
label='VRF', null_option=(0, 'Global'))
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('ip_addresses')),
to_field_name='slug', null_option=(0, 'None'))
family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address family')
mask_length = forms.ChoiceField(required=False, choices=IPADDRESS_MASK_LENGTH_CHOICES, label='Mask length')
vrf = FilterChoiceField(
queryset=VRF.objects.annotate(filter_count=Count('ip_addresses')),
to_field_name='rd',
label='VRF',
null_option=(0, 'Global')
)
tenant = FilterChoiceField(
queryset=Tenant.objects.annotate(filter_count=Count('ip_addresses')),
to_field_name='slug',
null_option=(0, 'None')
)
status = forms.MultipleChoiceField(choices=ipaddress_status_choices, required=False)
@@ -466,7 +539,11 @@ class VLANGroupForm(BootstrapMixin, forms.ModelForm):
class VLANGroupFilterForm(BootstrapMixin, forms.Form):
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('vlan_groups')), to_field_name='slug')
site = FilterChoiceField(
queryset=Site.objects.annotate(filter_count=Count('vlan_groups')),
to_field_name='slug',
null_option=(0, 'Global')
)
#
@@ -482,7 +559,7 @@ class VLANForm(BootstrapMixin, CustomFieldForm):
model = VLAN
fields = ['site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description']
help_texts = {
'site': "The site at which this VLAN exists",
'site': "Leave blank if this VLAN spans multiple sites",
'group': "VLAN group (optional)",
'vid': "Configured VLAN ID",
'name': "Configured VLAN name",
@@ -490,7 +567,7 @@ class VLANForm(BootstrapMixin, CustomFieldForm):
'role': "The primary function of this VLAN",
}
widgets = {
'site': forms.Select(attrs={'filter-for': 'group'}),
'site': forms.Select(attrs={'filter-for': 'group', 'nullable': 'true'}),
}
def __init__(self, *args, **kwargs):
@@ -503,11 +580,11 @@ class VLANForm(BootstrapMixin, CustomFieldForm):
elif self.initial.get('site'):
self.fields['group'].queryset = VLANGroup.objects.filter(site=self.initial['site'])
else:
self.fields['group'].choices = []
self.fields['group'].queryset = VLANGroup.objects.filter(site=None)
class VLANFromCSVForm(forms.ModelForm):
site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name',
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, to_field_name='name',
error_messages={'invalid_choice': 'Site not found.'})
group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False, to_field_name='name',
error_messages={'invalid_choice': 'VLAN group not found.'})
@@ -556,14 +633,28 @@ def vlan_status_choices():
class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = VLAN
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('vlans')), to_field_name='slug')
group_id = FilterChoiceField(queryset=VLANGroup.objects.annotate(filter_count=Count('vlans')), label='VLAN group',
null_option=(0, 'None'))
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('vlans')), to_field_name='slug',
null_option=(0, 'None'))
q = forms.CharField(required=False, label='Search')
site = FilterChoiceField(
queryset=Site.objects.annotate(filter_count=Count('vlans')),
to_field_name='slug',
null_option=(0, 'Global')
)
group_id = FilterChoiceField(
queryset=VLANGroup.objects.annotate(filter_count=Count('vlans')),
label='VLAN group',
null_option=(0, 'None')
)
tenant = FilterChoiceField(
queryset=Tenant.objects.annotate(filter_count=Count('vlans')),
to_field_name='slug',
null_option=(0, 'None')
)
status = forms.MultipleChoiceField(choices=vlan_status_choices, required=False)
role = FilterChoiceField(queryset=Role.objects.annotate(filter_count=Count('vlans')), to_field_name='slug',
null_option=(0, 'None'))
role = FilterChoiceField(
queryset=Role.objects.annotate(filter_count=Count('vlans')),
to_field_name='slug',
null_option=(0, 'None')
)
#

View File

@@ -1,4 +1,4 @@
from django.db.models import Lookup
from django.db.models import Lookup, Transform, IntegerField
from django.db.models.lookups import BuiltinLookup
@@ -87,3 +87,12 @@ class NetHost(Lookup):
rhs_params[0] = rhs_params[0].split('/')[0]
params = lhs_params + rhs_params
return 'HOST(%s) = %s' % (lhs, rhs), params
class NetMaskLength(Transform):
lookup_name = 'net_mask_length'
function = 'MASKLEN'
@property
def output_field(self):
return IntegerField()

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ from django.core.urlresolvers import reverse
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models.expressions import RawSQL
from django.utils.encoding import python_2_unicode_compatible
from dcim.models import Interface
from extras.models import CustomFieldModel, CustomFieldValue
@@ -36,10 +37,12 @@ PREFIX_STATUS_CHOICES = (
IPADDRESS_STATUS_ACTIVE = 1
IPADDRESS_STATUS_RESERVED = 2
IPADDRESS_STATUS_DEPRECATED = 3
IPADDRESS_STATUS_DHCP = 5
IPADDRESS_STATUS_CHOICES = (
(IPADDRESS_STATUS_ACTIVE, 'Active'),
(IPADDRESS_STATUS_RESERVED, 'Reserved'),
(IPADDRESS_STATUS_DEPRECATED, 'Deprecated'),
(IPADDRESS_STATUS_DHCP, 'DHCP')
)
@@ -70,6 +73,7 @@ IP_PROTOCOL_CHOICES = (
)
@python_2_unicode_compatible
class VRF(CreatedUpdatedModel, CustomFieldModel):
"""
A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing
@@ -89,7 +93,7 @@ class VRF(CreatedUpdatedModel, CustomFieldModel):
verbose_name = 'VRF'
verbose_name_plural = 'VRFs'
def __unicode__(self):
def __str__(self):
return self.name
def get_absolute_url(self):
@@ -105,6 +109,7 @@ class VRF(CreatedUpdatedModel, CustomFieldModel):
])
@python_2_unicode_compatible
class RIR(models.Model):
"""
A Regional Internet Registry (RIR) is responsible for the allocation of a large portion of the global IP address
@@ -120,13 +125,14 @@ class RIR(models.Model):
verbose_name = 'RIR'
verbose_name_plural = 'RIRs'
def __unicode__(self):
def __str__(self):
return self.name
def get_absolute_url(self):
return "{}?rir={}".format(reverse('ipam:aggregate_list'), self.slug)
@python_2_unicode_compatible
class Aggregate(CreatedUpdatedModel, CustomFieldModel):
"""
An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize
@@ -142,7 +148,7 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel):
class Meta:
ordering = ['family', 'prefix']
def __unicode__(self):
def __str__(self):
return str(self.prefix)
def get_absolute_url(self):
@@ -204,6 +210,7 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel):
return int(children_size / self.prefix.size * 100)
@python_2_unicode_compatible
class Role(models.Model):
"""
A Role represents the functional role of a Prefix or VLAN; for example, "Customer," "Infrastructure," or
@@ -216,7 +223,7 @@ class Role(models.Model):
class Meta:
ordering = ['weight', 'name']
def __unicode__(self):
def __str__(self):
return self.name
@property
@@ -263,6 +270,7 @@ class PrefixQuerySet(NullsFirstQuerySet):
return filter(lambda p: p.depth <= limit, queryset)
@python_2_unicode_compatible
class Prefix(CreatedUpdatedModel, CustomFieldModel):
"""
A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and
@@ -292,16 +300,20 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
ordering = ['vrf', 'family', 'prefix']
verbose_name_plural = 'prefixes'
def __unicode__(self):
def __str__(self):
return str(self.prefix)
def get_absolute_url(self):
return reverse('ipam:prefix', args=[self.pk])
def get_duplicates(self):
return Prefix.objects.filter(vrf=self.vrf, prefix=str(self.prefix)).exclude(pk=self.pk)
def clean(self):
# Disallow host masks
if self.prefix:
# Disallow host masks
if self.prefix.version == 4 and self.prefix.prefixlen == 32:
raise ValidationError({
'prefix': "Cannot create host addresses (/32) as prefixes. Create an IPv4 address instead."
@@ -311,6 +323,17 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
'prefix': "Cannot create host addresses (/128) as prefixes. Create an IPv6 address instead."
})
# Enforce unique IP space (if applicable)
if (self.vrf is None and settings.ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique):
duplicate_prefixes = self.get_duplicates()
if duplicate_prefixes:
raise ValidationError({
'prefix': "Duplicate prefix found in {}: {}".format(
"VRF {}".format(self.vrf) if self.vrf else "global table",
duplicate_prefixes.first(),
)
})
def save(self, *args, **kwargs):
if self.prefix:
# Clear host bits from prefix
@@ -362,6 +385,7 @@ class IPAddressManager(models.Manager):
return qs.annotate(host=RawSQL('INET(HOST(ipam_ipaddress.address))', [])).order_by('family', 'host')
@python_2_unicode_compatible
class IPAddress(CreatedUpdatedModel, CustomFieldModel):
"""
An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is
@@ -394,29 +418,29 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
verbose_name = 'IP address'
verbose_name_plural = 'IP addresses'
def __unicode__(self):
def __str__(self):
return str(self.address)
def get_absolute_url(self):
return reverse('ipam:ipaddress', args=[self.pk])
def get_duplicates(self):
return IPAddress.objects.filter(vrf=self.vrf, address__net_host=str(self.address.ip)).exclude(pk=self.pk)
def clean(self):
# Enforce unique IP space if applicable
if self.vrf and self.vrf.enforce_unique:
duplicate_ips = IPAddress.objects.filter(vrf=self.vrf, address__net_host=str(self.address.ip))\
.exclude(pk=self.pk)
if duplicate_ips:
raise ValidationError({
'address': "Duplicate IP address found in VRF {}: {}".format(self.vrf, duplicate_ips.first())
})
elif not self.vrf and settings.ENFORCE_GLOBAL_UNIQUE:
duplicate_ips = IPAddress.objects.filter(vrf=None, address__net_host=str(self.address.ip))\
.exclude(pk=self.pk)
if duplicate_ips:
raise ValidationError({
'address': "Duplicate IP address found in global table: {}".format(duplicate_ips.first())
})
if self.address:
# Enforce unique IP space (if applicable)
if (self.vrf is None and settings.ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique):
duplicate_ips = self.get_duplicates()
if duplicate_ips:
raise ValidationError({
'address': "Duplicate IP address found in {}: {}".format(
"VRF {}".format(self.vrf) if self.vrf else "global table",
duplicate_ips.first(),
)
})
def save(self, *args, **kwargs):
if self.address:
@@ -454,13 +478,14 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
return STATUS_CHOICE_CLASSES[self.status]
@python_2_unicode_compatible
class VLANGroup(models.Model):
"""
A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique.
"""
name = models.CharField(max_length=50)
slug = models.SlugField()
site = models.ForeignKey('dcim.Site', related_name='vlan_groups')
site = models.ForeignKey('dcim.Site', related_name='vlan_groups', on_delete=models.PROTECT, blank=True, null=True)
class Meta:
ordering = ['site', 'name']
@@ -471,13 +496,16 @@ class VLANGroup(models.Model):
verbose_name = 'VLAN group'
verbose_name_plural = 'VLAN groups'
def __unicode__(self):
def __str__(self):
if self.site is None:
return self.name
return u'{} - {}'.format(self.site.name, self.name)
def get_absolute_url(self):
return "{}?group_id={}".format(reverse('ipam:vlan_list'), self.pk)
@python_2_unicode_compatible
class VLAN(CreatedUpdatedModel, CustomFieldModel):
"""
A VLAN is a distinct layer two forwarding domain identified by a 12-bit integer (1-4094). Each VLAN must be assigned
@@ -487,7 +515,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
Like Prefixes, each VLAN is assigned an operational status and optionally a user-defined Role. A VLAN can have zero
or more Prefixes assigned to it.
"""
site = models.ForeignKey('dcim.Site', related_name='vlans', on_delete=models.PROTECT)
site = models.ForeignKey('dcim.Site', related_name='vlans', on_delete=models.PROTECT, blank=True, null=True)
group = models.ForeignKey('VLANGroup', related_name='vlans', blank=True, null=True, on_delete=models.PROTECT)
vid = models.PositiveSmallIntegerField(verbose_name='ID', validators=[
MinValueValidator(1),
@@ -509,7 +537,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
verbose_name = 'VLAN'
verbose_name_plural = 'VLANs'
def __unicode__(self):
def __str__(self):
return self.display_name
def get_absolute_url(self):
@@ -525,7 +553,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
def to_csv(self):
return csv_format([
self.site.name,
self.site.name if self.site else None,
self.group.name if self.group else None,
self.vid,
self.name,
@@ -543,6 +571,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
return STATUS_CHOICE_CLASSES[self.status]
@python_2_unicode_compatible
class Service(CreatedUpdatedModel):
"""
A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device. A Service may optionally be tied
@@ -561,8 +590,5 @@ class Service(CreatedUpdatedModel):
ordering = ['device', 'protocol', 'port']
unique_together = ['device', 'protocol', 'port']
def __unicode__(self):
def __str__(self):
return u'{} ({}/{})'.format(self.name, self.port, self.get_protocol_display())
def get_parent_url(self):
return self.device.get_absolute_url()

View File

@@ -136,7 +136,7 @@ class VRFTable(BaseTable):
name = tables.LinkColumn('ipam:vrf', args=[Accessor('pk')], verbose_name='Name')
rd = tables.Column(verbose_name='RD')
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
description = tables.Column(orderable=False, verbose_name='Description')
description = tables.Column(verbose_name='Description')
class Meta(BaseTable.Meta):
model = VRF
@@ -182,7 +182,7 @@ class AggregateTable(BaseTable):
child_count = tables.Column(verbose_name='Prefixes')
get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
date_added = tables.DateColumn(format="Y-m-d", verbose_name='Added')
description = tables.Column(orderable=False, verbose_name='Description')
description = tables.Column(verbose_name='Description')
class Meta(BaseTable.Meta):
model = Aggregate
@@ -219,7 +219,7 @@ class PrefixTable(BaseTable):
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
vlan = tables.LinkColumn('ipam:vlan', args=[Accessor('vlan.pk')], verbose_name='VLAN')
role = tables.TemplateColumn(PREFIX_ROLE_LINK, verbose_name='Role')
description = tables.Column(orderable=False, verbose_name='Description')
description = tables.Column(verbose_name='Description')
class Meta(BaseTable.Meta):
model = Prefix
@@ -234,11 +234,12 @@ class PrefixBriefTable(BaseTable):
vrf = tables.LinkColumn('ipam:vrf', args=[Accessor('vrf.pk')], default='Global', verbose_name='VRF')
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
vlan = tables.LinkColumn('ipam:vlan', args=[Accessor('vlan.pk')], verbose_name='VLAN')
role = tables.Column(verbose_name='Role')
class Meta(BaseTable.Meta):
model = Prefix
fields = ('prefix', 'vrf', 'status', 'site', 'role')
fields = ('prefix', 'vrf', 'status', 'site', 'vlan', 'role')
orderable = False
@@ -255,7 +256,7 @@ class IPAddressTable(BaseTable):
device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False,
verbose_name='Device')
interface = tables.Column(orderable=False, verbose_name='Interface')
description = tables.Column(orderable=False, verbose_name='Description')
description = tables.Column(verbose_name='Description')
class Meta(BaseTable.Meta):
model = IPAddress
@@ -310,7 +311,8 @@ class VLANTable(BaseTable):
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
role = tables.TemplateColumn(VLAN_ROLE_LINK, verbose_name='Role')
description = tables.Column(verbose_name='Description')
class Meta(BaseTable.Meta):
model = VLAN
fields = ('pk', 'vid', 'site', 'group', 'name', 'prefixes', 'tenant', 'status', 'role')
fields = ('pk', 'vid', 'site', 'group', 'name', 'prefixes', 'tenant', 'status', 'role', 'description')

View File

View File

@@ -0,0 +1,60 @@
import netaddr
from django.test import TestCase, override_settings
from ipam.models import IPAddress, Prefix, VRF
from django.core.exceptions import ValidationError
class TestPrefix(TestCase):
@override_settings(ENFORCE_GLOBAL_UNIQUE=False)
def test_duplicate_global(self):
Prefix.objects.create(prefix=netaddr.IPNetwork('192.0.2.0/24'))
duplicate_prefix = Prefix(prefix=netaddr.IPNetwork('192.0.2.0/24'))
self.assertIsNone(duplicate_prefix.clean())
@override_settings(ENFORCE_GLOBAL_UNIQUE=True)
def test_duplicate_global_unique(self):
Prefix.objects.create(prefix=netaddr.IPNetwork('192.0.2.0/24'))
duplicate_prefix = Prefix(prefix=netaddr.IPNetwork('192.0.2.0/24'))
self.assertRaises(ValidationError, duplicate_prefix.clean)
def test_duplicate_vrf(self):
vrf = VRF.objects.create(name='Test', rd='1:1', enforce_unique=False)
Prefix.objects.create(vrf=vrf, prefix=netaddr.IPNetwork('192.0.2.0/24'))
duplicate_prefix = Prefix(vrf=vrf, prefix=netaddr.IPNetwork('192.0.2.0/24'))
self.assertIsNone(duplicate_prefix.clean())
def test_duplicate_vrf_unique(self):
vrf = VRF.objects.create(name='Test', rd='1:1', enforce_unique=True)
Prefix.objects.create(vrf=vrf, prefix=netaddr.IPNetwork('192.0.2.0/24'))
duplicate_prefix = Prefix(vrf=vrf, prefix=netaddr.IPNetwork('192.0.2.0/24'))
self.assertRaises(ValidationError, duplicate_prefix.clean)
class TestIPAddress(TestCase):
@override_settings(ENFORCE_GLOBAL_UNIQUE=False)
def test_duplicate_global(self):
IPAddress.objects.create(address=netaddr.IPNetwork('192.0.2.1/24'))
duplicate_ip = IPAddress(address=netaddr.IPNetwork('192.0.2.1/24'))
self.assertIsNone(duplicate_ip.clean())
@override_settings(ENFORCE_GLOBAL_UNIQUE=True)
def test_duplicate_global_unique(self):
IPAddress.objects.create(address=netaddr.IPNetwork('192.0.2.1/24'))
duplicate_ip = IPAddress(address=netaddr.IPNetwork('192.0.2.1/24'))
self.assertRaises(ValidationError, duplicate_ip.clean)
def test_duplicate_vrf(self):
vrf = VRF.objects.create(name='Test', rd='1:1', enforce_unique=False)
IPAddress.objects.create(vrf=vrf, address=netaddr.IPNetwork('192.0.2.1/24'))
duplicate_ip = IPAddress(vrf=vrf, address=netaddr.IPNetwork('192.0.2.1/24'))
self.assertIsNone(duplicate_ip.clean())
def test_duplicate_vrf_unique(self):
vrf = VRF.objects.create(name='Test', rd='1:1', enforce_unique=True)
IPAddress.objects.create(vrf=vrf, address=netaddr.IPNetwork('192.0.2.1/24'))
duplicate_ip = IPAddress(vrf=vrf, address=netaddr.IPNetwork('192.0.2.1/24'))
self.assertRaises(ValidationError, duplicate_ip.clean)

View File

@@ -95,15 +95,16 @@ class VRFListView(ObjectListView):
filter = filters.VRFFilter
filter_form = forms.VRFFilterForm
table = tables.VRFTable
edit_permissions = ['ipam.change_vrf', 'ipam.delete_vrf']
template_name = 'ipam/vrf_list.html'
def vrf(request, pk):
vrf = get_object_or_404(VRF.objects.all(), pk=pk)
prefixes = Prefix.objects.filter(vrf=vrf)
prefix_table = tables.PrefixBriefTable(prefixes)
prefix_table = tables.PrefixBriefTable(
list(Prefix.objects.filter(vrf=vrf).select_related('site', 'role'))
)
prefix_table.exclude = ('vrf',)
return render(request, 'ipam/vrf.html', {
'vrf': vrf,
@@ -116,13 +117,13 @@ class VRFEditView(PermissionRequiredMixin, ObjectEditView):
model = VRF
form_class = forms.VRFForm
template_name = 'ipam/vrf_edit.html'
obj_list_url = 'ipam:vrf_list'
default_return_url = 'ipam:vrf_list'
class VRFDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_vrf'
model = VRF
redirect_url = 'ipam:vrf_list'
default_return_url = 'ipam:vrf_list'
class VRFBulkImportView(PermissionRequiredMixin, BulkImportView):
@@ -130,21 +131,23 @@ class VRFBulkImportView(PermissionRequiredMixin, BulkImportView):
form = forms.VRFImportForm
table = tables.VRFTable
template_name = 'ipam/vrf_import.html'
obj_list_url = 'ipam:vrf_list'
default_return_url = 'ipam:vrf_list'
class VRFBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'ipam.change_vrf'
cls = VRF
filter = filters.VRFFilter
form = forms.VRFBulkEditForm
template_name = 'ipam/vrf_bulk_edit.html'
default_redirect_url = 'ipam:vrf_list'
default_return_url = 'ipam:vrf_list'
class VRFBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_vrf'
cls = VRF
default_redirect_url = 'ipam:vrf_list'
filter = filters.VRFFilter
default_return_url = 'ipam:vrf_list'
#
@@ -156,7 +159,6 @@ class RIRListView(ObjectListView):
filter = filters.RIRFilter
filter_form = forms.RIRFilterForm
table = tables.RIRTable
edit_permissions = ['ipam.change_rir', 'ipam.delete_rir']
template_name = 'ipam/rir_list.html'
def alter_queryset(self, request):
@@ -240,14 +242,16 @@ class RIREditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.change_rir'
model = RIR
form_class = forms.RIRForm
obj_list_url = 'ipam:rir_list'
use_obj_view = False
def get_return_url(self, obj):
return reverse('ipam:rir_list')
class RIRBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_rir'
cls = RIR
default_redirect_url = 'ipam:rir_list'
filter = filters.RIRFilter
default_return_url = 'ipam:rir_list'
#
@@ -261,7 +265,6 @@ class AggregateListView(ObjectListView):
filter = filters.AggregateFilter
filter_form = forms.AggregateFilterForm
table = tables.AggregateTable
edit_permissions = ['ipam.change_aggregate', 'ipam.delete_aggregate']
template_name = 'ipam/aggregate_list.html'
def extra_context(self):
@@ -294,9 +297,17 @@ def aggregate(request, pk):
prefix_table.base_columns['pk'].visible = True
RequestConfig(request, paginate={'klass': EnhancedPaginator}).configure(prefix_table)
# Compile permissions list for rendering the object table
permissions = {
'add': request.user.has_perm('ipam.add_prefix'),
'change': request.user.has_perm('ipam.change_prefix'),
'delete': request.user.has_perm('ipam.delete_prefix'),
}
return render(request, 'ipam/aggregate.html', {
'aggregate': aggregate,
'prefix_table': prefix_table,
'permissions': permissions,
})
@@ -305,13 +316,13 @@ class AggregateEditView(PermissionRequiredMixin, ObjectEditView):
model = Aggregate
form_class = forms.AggregateForm
template_name = 'ipam/aggregate_edit.html'
obj_list_url = 'ipam:aggregate_list'
default_return_url = 'ipam:aggregate_list'
class AggregateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_aggregate'
model = Aggregate
redirect_url = 'ipam:aggregate_list'
default_return_url = 'ipam:aggregate_list'
class AggregateBulkImportView(PermissionRequiredMixin, BulkImportView):
@@ -319,21 +330,23 @@ class AggregateBulkImportView(PermissionRequiredMixin, BulkImportView):
form = forms.AggregateImportForm
table = tables.AggregateTable
template_name = 'ipam/aggregate_import.html'
obj_list_url = 'ipam:aggregate_list'
default_return_url = 'ipam:aggregate_list'
class AggregateBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'ipam.change_aggregate'
cls = Aggregate
filter = filters.AggregateFilter
form = forms.AggregateBulkEditForm
template_name = 'ipam/aggregate_bulk_edit.html'
default_redirect_url = 'ipam:aggregate_list'
default_return_url = 'ipam:aggregate_list'
class AggregateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_aggregate'
cls = Aggregate
default_redirect_url = 'ipam:aggregate_list'
filter = filters.AggregateFilter
default_return_url = 'ipam:aggregate_list'
#
@@ -343,7 +356,6 @@ class AggregateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class RoleListView(ObjectListView):
queryset = Role.objects.all()
table = tables.RoleTable
edit_permissions = ['ipam.change_role', 'ipam.delete_role']
template_name = 'ipam/role_list.html'
@@ -351,14 +363,15 @@ class RoleEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.change_role'
model = Role
form_class = forms.RoleForm
obj_list_url = 'ipam:role_list'
use_obj_view = False
def get_return_url(self, obj):
return reverse('ipam:role_list')
class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_role'
cls = Role
default_redirect_url = 'ipam:role_list'
default_return_url = 'ipam:role_list'
#
@@ -370,7 +383,6 @@ class PrefixListView(ObjectListView):
filter = filters.PrefixFilter
filter_form = forms.PrefixFilterForm
table = tables.PrefixTable
edit_permissions = ['ipam.change_prefix', 'ipam.delete_prefix']
template_name = 'ipam/prefix_list.html'
def alter_queryset(self, request):
@@ -381,7 +393,9 @@ class PrefixListView(ObjectListView):
def prefix(request, pk):
prefix = get_object_or_404(Prefix.objects.select_related('site', 'vlan', 'role'), pk=pk)
prefix = get_object_or_404(Prefix.objects.select_related(
'vrf', 'site__region', 'tenant__group', 'vlan__group', 'role'
), pk=pk)
try:
aggregate = Aggregate.objects.get(prefix__net_contains_or_equals=str(prefix.prefix))
@@ -397,11 +411,13 @@ def prefix(request, pk):
.filter(prefix__net_contains=str(prefix.prefix))\
.select_related('site', 'role').annotate_depth()
parent_prefix_table = tables.PrefixBriefTable(parent_prefixes)
parent_prefix_table.exclude = ('vrf',)
# Duplicate prefixes table
duplicate_prefixes = Prefix.objects.filter(vrf=prefix.vrf, prefix=str(prefix.prefix)).exclude(pk=prefix.pk)\
.select_related('site', 'role')
duplicate_prefix_table = tables.PrefixBriefTable(duplicate_prefixes)
duplicate_prefix_table = tables.PrefixBriefTable(list(duplicate_prefixes))
duplicate_prefix_table.exclude = ('vrf',)
# Child prefixes table
if prefix.vrf:
@@ -419,6 +435,13 @@ def prefix(request, pk):
child_prefix_table.base_columns['pk'].visible = True
RequestConfig(request, paginate={'klass': EnhancedPaginator}).configure(child_prefix_table)
# Compile permissions list for rendering the object table
permissions = {
'add': request.user.has_perm('ipam.add_prefix'),
'change': request.user.has_perm('ipam.change_prefix'),
'delete': request.user.has_perm('ipam.delete_prefix'),
}
return render(request, 'ipam/prefix.html', {
'prefix': prefix,
'aggregate': aggregate,
@@ -426,6 +449,8 @@ def prefix(request, pk):
'parent_prefix_table': parent_prefix_table,
'child_prefix_table': child_prefix_table,
'duplicate_prefix_table': duplicate_prefix_table,
'permissions': permissions,
'return_url': prefix.get_absolute_url(),
})
@@ -435,13 +460,14 @@ class PrefixEditView(PermissionRequiredMixin, ObjectEditView):
form_class = forms.PrefixForm
template_name = 'ipam/prefix_edit.html'
fields_initial = ['vrf', 'tenant', 'site', 'prefix', 'vlan']
obj_list_url = 'ipam:prefix_list'
default_return_url = 'ipam:prefix_list'
class PrefixDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_prefix'
model = Prefix
redirect_url = 'ipam:prefix_list'
template_name = 'ipam/prefix_delete.html'
default_return_url = 'ipam:prefix_list'
class PrefixBulkImportView(PermissionRequiredMixin, BulkImportView):
@@ -449,21 +475,23 @@ class PrefixBulkImportView(PermissionRequiredMixin, BulkImportView):
form = forms.PrefixImportForm
table = tables.PrefixTable
template_name = 'ipam/prefix_import.html'
obj_list_url = 'ipam:prefix_list'
default_return_url = 'ipam:prefix_list'
class PrefixBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'ipam.change_prefix'
cls = Prefix
filter = filters.PrefixFilter
form = forms.PrefixBulkEditForm
template_name = 'ipam/prefix_bulk_edit.html'
default_redirect_url = 'ipam:prefix_list'
default_return_url = 'ipam:prefix_list'
class PrefixBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_prefix'
cls = Prefix
default_redirect_url = 'ipam:prefix_list'
filter = filters.PrefixFilter
default_return_url = 'ipam:prefix_list'
def prefix_ipaddresses(request, pk):
@@ -480,9 +508,17 @@ def prefix_ipaddresses(request, pk):
ip_table.base_columns['pk'].visible = True
RequestConfig(request, paginate={'klass': EnhancedPaginator}).configure(ip_table)
# Compile permissions list for rendering the object table
permissions = {
'add': request.user.has_perm('ipam.add_ipaddress'),
'change': request.user.has_perm('ipam.change_ipaddress'),
'delete': request.user.has_perm('ipam.delete_ipaddress'),
}
return render(request, 'ipam/prefix_ipaddresses.html', {
'prefix': prefix,
'ip_table': ip_table,
'permissions': permissions,
})
@@ -495,7 +531,6 @@ class IPAddressListView(ObjectListView):
filter = filters.IPAddressFilter
filter_form = forms.IPAddressFilterForm
table = tables.IPAddressTable
edit_permissions = ['ipam.change_ipaddress', 'ipam.delete_ipaddress']
template_name = 'ipam/ipaddress_list.html'
@@ -504,18 +539,20 @@ 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))
parent_prefixes_table = tables.PrefixBriefTable(parent_prefixes)
parent_prefixes = Prefix.objects.filter(vrf=ipaddress.vrf, prefix__net_contains=str(ipaddress.address.ip))\
.select_related('site', 'role')
parent_prefixes_table = tables.PrefixBriefTable(list(parent_prefixes))
parent_prefixes_table.exclude = ('vrf',)
# 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)
duplicate_ips_table = tables.IPAddressBriefTable(list(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)
related_ips_table = tables.IPAddressBriefTable(list(related_ips))
return render(request, 'ipam/ipaddress.html', {
'ipaddress': ipaddress,
@@ -548,6 +585,8 @@ def ipaddress_assign(request, pk):
device.save()
return redirect('ipam:ipaddress', pk=ipaddress.pk)
else:
assert False, form.errors
else:
form = forms.IPAddressAssignForm()
@@ -555,7 +594,7 @@ def ipaddress_assign(request, pk):
return render(request, 'ipam/ipaddress_assign.html', {
'ipaddress': ipaddress,
'form': form,
'cancel_url': reverse('ipam:ipaddress', kwargs={'pk': ipaddress.pk}),
'return_url': reverse('ipam:ipaddress', kwargs={'pk': ipaddress.pk}),
})
@@ -588,7 +627,7 @@ def ipaddress_remove(request, pk):
return render(request, 'ipam/ipaddress_unassign.html', {
'ipaddress': ipaddress,
'form': form,
'cancel_url': reverse('ipam:ipaddress', kwargs={'pk': ipaddress.pk}),
'return_url': reverse('ipam:ipaddress', kwargs={'pk': ipaddress.pk}),
})
@@ -598,13 +637,13 @@ class IPAddressEditView(PermissionRequiredMixin, ObjectEditView):
form_class = forms.IPAddressForm
fields_initial = ['address', 'vrf']
template_name = 'ipam/ipaddress_edit.html'
obj_list_url = 'ipam:ipaddress_list'
default_return_url = 'ipam:ipaddress_list'
class IPAddressDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_ipaddress'
model = IPAddress
redirect_url = 'ipam:ipaddress_list'
default_return_url = 'ipam:ipaddress_list'
class IPAddressBulkAddView(PermissionRequiredMixin, BulkAddView):
@@ -612,7 +651,7 @@ class IPAddressBulkAddView(PermissionRequiredMixin, BulkAddView):
form = forms.IPAddressBulkAddForm
model = IPAddress
template_name = 'ipam/ipaddress_bulk_add.html'
redirect_url = 'ipam:ipaddress_list'
default_return_url = 'ipam:ipaddress_list'
class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView):
@@ -620,20 +659,18 @@ class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView):
form = forms.IPAddressImportForm
table = tables.IPAddressTable
template_name = 'ipam/ipaddress_import.html'
obj_list_url = 'ipam:ipaddress_list'
default_return_url = 'ipam:ipaddress_list'
def save_obj(self, obj):
obj.save()
# Update primary IP for device if needed
# Update primary IP for device if needed. The Device must be updated directly in the database; otherwise we risk
# overwriting a previous IP assignment from the same import (see #861).
try:
if obj.family == 4 and obj.primary_ip4_for:
device = obj.primary_ip4_for
device.primary_ip4 = obj
device.save()
Device.objects.filter(pk=obj.primary_ip4_for.pk).update(primary_ip4=obj)
elif obj.family == 6 and obj.primary_ip6_for:
device = obj.primary_ip6_for
device.primary_ip6 = obj
device.save()
Device.objects.filter(pk=obj.primary_ip6_for.pk).update(primary_ip6=obj)
except Device.DoesNotExist:
pass
@@ -641,15 +678,17 @@ class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView):
class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'ipam.change_ipaddress'
cls = IPAddress
filter = filters.IPAddressFilter
form = forms.IPAddressBulkEditForm
template_name = 'ipam/ipaddress_bulk_edit.html'
default_redirect_url = 'ipam:ipaddress_list'
default_return_url = 'ipam:ipaddress_list'
class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_ipaddress'
cls = IPAddress
default_redirect_url = 'ipam:ipaddress_list'
filter = filters.IPAddressFilter
default_return_url = 'ipam:ipaddress_list'
#
@@ -661,7 +700,6 @@ class VLANGroupListView(ObjectListView):
filter = filters.VLANGroupFilter
filter_form = forms.VLANGroupFilterForm
table = tables.VLANGroupTable
edit_permissions = ['ipam.change_vlangroup', 'ipam.delete_vlangroup']
template_name = 'ipam/vlangroup_list.html'
@@ -669,14 +707,16 @@ class VLANGroupEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.change_vlangroup'
model = VLANGroup
form_class = forms.VLANGroupForm
obj_list_url = 'ipam:vlangroup_list'
use_obj_view = False
def get_return_url(self, obj):
return reverse('ipam:vlangroup_list')
class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_vlangroup'
cls = VLANGroup
default_redirect_url = 'ipam:vlangroup_list'
filter = filters.VLANGroupFilter
default_return_url = 'ipam:vlangroup_list'
#
@@ -688,15 +728,15 @@ class VLANListView(ObjectListView):
filter = filters.VLANFilter
filter_form = forms.VLANFilterForm
table = tables.VLANTable
edit_permissions = ['ipam.change_vlan', 'ipam.delete_vlan']
template_name = 'ipam/vlan_list.html'
def vlan(request, pk):
vlan = get_object_or_404(VLAN.objects.select_related('site', 'role'), pk=pk)
prefixes = Prefix.objects.filter(vlan=vlan)
prefix_table = tables.PrefixBriefTable(prefixes)
vlan = get_object_or_404(VLAN.objects.select_related('site__region', 'tenant__group', 'role'), pk=pk)
prefixes = Prefix.objects.filter(vlan=vlan).select_related('vrf', 'site', 'role')
prefix_table = tables.PrefixBriefTable(list(prefixes))
prefix_table.exclude = ('vlan',)
return render(request, 'ipam/vlan.html', {
'vlan': vlan,
@@ -709,13 +749,13 @@ class VLANEditView(PermissionRequiredMixin, ObjectEditView):
model = VLAN
form_class = forms.VLANForm
template_name = 'ipam/vlan_edit.html'
obj_list_url = 'ipam:vlan_list'
default_return_url = 'ipam:vlan_list'
class VLANDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_vlan'
model = VLAN
redirect_url = 'ipam:vlan_list'
default_return_url = 'ipam:vlan_list'
class VLANBulkImportView(PermissionRequiredMixin, BulkImportView):
@@ -723,21 +763,23 @@ class VLANBulkImportView(PermissionRequiredMixin, BulkImportView):
form = forms.VLANImportForm
table = tables.VLANTable
template_name = 'ipam/vlan_import.html'
obj_list_url = 'ipam:vlan_list'
default_return_url = 'ipam:vlan_list'
class VLANBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'ipam.change_vlan'
cls = VLAN
filter = filters.VLANFilter
form = forms.VLANBulkEditForm
template_name = 'ipam/vlan_bulk_edit.html'
default_redirect_url = 'ipam:vlan_list'
default_return_url = 'ipam:vlan_list'
class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_vlan'
cls = VLAN
default_redirect_url = 'ipam:vlan_list'
filter = filters.VLANFilter
default_return_url = 'ipam:vlan_list'
#
@@ -750,11 +792,14 @@ class ServiceEditView(PermissionRequiredMixin, ObjectEditView):
form_class = forms.ServiceForm
template_name = 'ipam/service_edit.html'
def alter_obj(self, obj, args, kwargs):
if 'device' in kwargs:
obj.device = get_object_or_404(Device, pk=kwargs['device'])
def alter_obj(self, obj, request, url_args, url_kwargs):
if 'device' in url_kwargs:
obj.device = get_object_or_404(Device, pk=url_kwargs['device'])
return obj
def get_return_url(self, obj):
return obj.device.get_absolute_url()
class ServiceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_service'

View File

@@ -6,13 +6,13 @@ from django.contrib.messages import constants as messages
from django.core.exceptions import ImproperlyConfigured
try:
import configuration
from netbox import configuration
except ImportError:
raise ImproperlyConfigured("Configuration file is not present. Please define netbox/netbox/configuration.py per "
"the documentation.")
VERSION = '1.8.1'
VERSION = '1.9.0-r1'
# Import local configuration
for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:
@@ -50,7 +50,7 @@ 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 *
from netbox.ldap_config import *
LDAP_CONFIGURED = True
except ImportError:
LDAP_CONFIGURED = False
@@ -104,6 +104,7 @@ INSTALLED_APPS = (
'django.contrib.humanize',
'debug_toolbar',
'django_tables2',
'mptt',
'rest_framework',
'rest_framework_swagger',
'circuits',
@@ -189,11 +190,6 @@ REST_FRAMEWORK = {
if LOGIN_REQUIRED:
REST_FRAMEWORK['DEFAULT_PERMISSION_CLASSES'] = ('rest_framework.permissions.IsAuthenticated',)
# Swagger settings (API docs)
SWAGGER_SETTINGS = {
'base_path': '{}/{}api/docs'.format(ALLOWED_HOSTS[0], BASE_PATH),
}
# Django debug toolbar
INTERNAL_IPS = (
'127.0.0.1',

View File

@@ -2,7 +2,7 @@ from django.conf import settings
from django.conf.urls import include, url
from django.contrib import admin
from views import home, handle_500, trigger_500
from netbox.views import home, handle_500, trigger_500
from users.views import login, logout

View File

@@ -264,6 +264,15 @@ ul.rack_far_face li.blocked {
#ffc7c7 14px
);
}
ul.rack_near_face li.reserved {
background: repeating-linear-gradient(
45deg,
#f7f7f7,
#f7f7f7 7px,
#c7c7ff 7px,
#c7c7ff 14px
);
}
ul.rack_near_face {
z-index: 200;
}

View File

@@ -9,6 +9,14 @@ $(document).ready(function() {
$('#select_all').prop('checked', false);
}
});
// Enable hidden buttons when "select all" is checked
$('#select_all').click(function (event) {
if ($(this).is(':checked')) {
$('#select_all_box').find('button').prop('disabled', '');
} else {
$('#select_all_box').find('button').prop('disabled', 'disabled');
}
});
// Uncheck the "toggle all" checkbox if an item is unchecked
$('input:checkbox[name=pk]').click(function (event) {
if (!$(this).attr('checked')) {
@@ -60,37 +68,38 @@ $(document).ready(function() {
});
// API select widget
$('select[filter-for]').change(function () {
$('select[filter-for]').change(function() {
// Resolve child field by ID specified in parent
var child_name = $(this).attr('filter-for');
var child_field = $('#id_' + child_name);
var child_selected = child_field.val();
// Wipe out any existing options within the child field
// Wipe out any existing options within the child field and create a default option
child_field.empty();
child_field.append($("<option></option>").attr("value", "").text(""));
if ($(this).val()) {
child_field.append($("<option></option>").attr("value", "").text("---------"));
if ($(this).val() || $(this).attr('nullable') == 'true') {
var api_url = child_field.attr('api-url');
var disabled_indicator = child_field.attr('disabled-indicator');
var initial_value = child_field.attr('initial');
var display_field = child_field.attr('display-field') || 'name';
// Gather the values of all other filter fields for this child
$("select[filter-for='" + child_name + "']").each(function() {
var filter_field = $(this);
// Determine the filter fields needed to make an API call
var filter_regex = /\{\{([a-z_]+)\}\}/g;
var match;
while (match = filter_regex.exec(api_url)) {
var filter_field = $('#id_' + match[1]);
if (filter_field.val()) {
api_url = api_url.replace('{{' + filter_field.attr('name') + '}}', filter_field.val());
} else {
// Not all filters have been selected yet
return false;
api_url = api_url.replace(match[0], filter_field.val());
} else if ($(this).attr('nullable') == 'true') {
api_url = api_url.replace(match[0], '0');
}
});
}
// If all URL variables have been replaced, make the API call
if (api_url.search('{{') < 0) {
console.log(child_name + ": Fetching " + api_url);
$.ajax({
url: api_url,
dataType: 'json',
@@ -98,7 +107,9 @@ $(document).ready(function() {
$.each(response, function (index, choice) {
var option = $("<option></option>").attr("value", choice.id).text(choice[display_field]);
if (disabled_indicator && choice[disabled_indicator] && choice.id != initial_value) {
option.attr("disabled", "disabled")
option.attr("disabled", "disabled");
} else if (choice.id == child_selected) {
option.attr("selected", "selected");
}
child_field.append(option);
});

View File

@@ -48,7 +48,7 @@ $(document).ready(function() {
$('#generate_keypair').click(function() {
$('#new_keypair_modal').modal('show');
$.ajax({
url: '/api/secrets/generate-keys/',
url: netbox_api_path + 'secrets/generate-keys/',
type: 'GET',
dataType: 'json',
success: function (response, status) {
@@ -75,7 +75,7 @@ $(document).ready(function() {
function unlock_secret(secret_id, private_key) {
var csrf_token = $('input[name=csrfmiddlewaretoken]').val();
$.ajax({
url: '/api/secrets/secrets/' + secret_id + '/',
url: netbox_api_path + 'secrets/secrets/' + secret_id + '/',
type: 'POST',
data: {
private_key: private_key

View File

@@ -7,8 +7,8 @@ from dcim.models import Device
class SecretFilter(django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
q = django_filters.CharFilter(
method='search',
label='Search',
)
role_id = django_filters.ModelMultipleChoiceFilter(
@@ -33,7 +33,9 @@ class SecretFilter(django_filters.FilterSet):
model = Secret
fields = ['name']
def search(self, queryset, value):
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(device__name__icontains=value)

View File

@@ -100,7 +100,11 @@ class SecretBulkEditForm(BootstrapMixin, BulkEditForm):
class SecretFilterForm(BootstrapMixin, forms.Form):
role = FilterChoiceField(queryset=SecretRole.objects.annotate(filter_count=Count('secrets')), to_field_name='slug')
q = forms.CharField(required=False, label='Search')
role = FilterChoiceField(
queryset=SecretRole.objects.annotate(filter_count=Count('secrets')),
to_field_name='slug'
)
#

View File

@@ -8,7 +8,7 @@ from django.contrib.auth.models import Group, User
from django.core.exceptions import ValidationError
from django.core.urlresolvers import reverse
from django.db import models
from django.utils.encoding import force_bytes
from django.utils.encoding import force_bytes, python_2_unicode_compatible
from dcim.models import Device
from utilities.models import CreatedUpdatedModel
@@ -51,6 +51,7 @@ class UserKeyQuerySet(models.QuerySet):
raise Exception("Bulk deletion has been disabled.")
@python_2_unicode_compatible
class UserKey(CreatedUpdatedModel):
"""
A UserKey stores a user's personal RSA (public) encryption key, which is used to generate their unique encrypted
@@ -76,7 +77,7 @@ class UserKey(CreatedUpdatedModel):
self.__initial_public_key = self.public_key
self.__initial_master_key_cipher = self.master_key_cipher
def __unicode__(self):
def __str__(self):
return self.user.username
def clean(self, *args, **kwargs):
@@ -170,6 +171,7 @@ class UserKey(CreatedUpdatedModel):
self.save()
@python_2_unicode_compatible
class SecretRole(models.Model):
"""
A SecretRole represents an arbitrary functional classification of Secrets. For example, a user might define roles
@@ -186,7 +188,7 @@ class SecretRole(models.Model):
class Meta:
ordering = ['name']
def __unicode__(self):
def __str__(self):
return self.name
def get_absolute_url(self):
@@ -201,6 +203,7 @@ class SecretRole(models.Model):
return user in self.users.all() or user.groups.filter(pk__in=self.groups.all()).exists()
@python_2_unicode_compatible
class Secret(CreatedUpdatedModel):
"""
A Secret stores an AES256-encrypted copy of sensitive data, such as passwords or secret keys. An irreversible
@@ -227,7 +230,7 @@ class Secret(CreatedUpdatedModel):
self.plaintext = kwargs.pop('plaintext', None)
super(Secret, self).__init__(*args, **kwargs)
def __unicode__(self):
def __str__(self):
if self.role and self.device:
return u'{} for {}'.format(self.role, self.device)
return u'Secret'

View File

@@ -1 +0,0 @@
from test_models import *

View File

@@ -22,7 +22,6 @@ from .models import SecretRole, Secret, UserKey
class SecretRoleListView(ObjectListView):
queryset = SecretRole.objects.annotate(secret_count=Count('secrets'))
table = tables.SecretRoleTable
edit_permissions = ['secrets.change_secretrole', 'secrets.delete_secretrole']
template_name = 'secrets/secretrole_list.html'
@@ -30,14 +29,15 @@ class SecretRoleEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'secrets.change_secretrole'
model = SecretRole
form_class = forms.SecretRoleForm
obj_list_url = 'secrets:secretrole_list'
use_obj_view = False
def get_return_url(self, obj):
return reverse('secrets:secretrole_list')
class SecretRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'secrets.delete_secretrole'
cls = SecretRole
default_redirect_url = 'secrets:secretrole_list'
default_return_url = 'secrets:secretrole_list'
#
@@ -50,7 +50,6 @@ class SecretListView(ObjectListView):
filter = filters.SecretFilter
filter_form = forms.SecretFilterForm
table = tables.SecretTable
edit_permissions = ['secrets.change_secret', 'secrets.delete_secret']
template_name = 'secrets/secret_list.html'
@@ -102,7 +101,7 @@ def secret_add(request, pk):
return render(request, 'secrets/secret_edit.html', {
'secret': secret,
'form': form,
'cancel_url': device.get_absolute_url(),
'return_url': device.get_absolute_url(),
})
@@ -144,14 +143,14 @@ def secret_edit(request, pk):
return render(request, 'secrets/secret_edit.html', {
'secret': secret,
'form': form,
'cancel_url': reverse('secrets:secret', kwargs={'pk': secret.pk}),
'return_url': reverse('secrets:secret', kwargs={'pk': secret.pk}),
})
class SecretDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'secrets.delete_secret'
model = Secret
redirect_url = 'secrets:secret_list'
default_return_url = 'secrets:secret_list'
@permission_required('secrets.add_secret')
@@ -194,19 +193,21 @@ def secret_import(request):
return render(request, 'secrets/secret_import.html', {
'form': form,
'cancel_url': reverse('secrets:secret_list'),
'return_url': reverse('secrets:secret_list'),
})
class SecretBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'secrets.change_secret'
cls = Secret
filter = filters.SecretFilter
form = forms.SecretBulkEditForm
template_name = 'secrets/secret_bulk_edit.html'
default_redirect_url = 'secrets:secret_list'
default_return_url = 'secrets:secret_list'
class SecretBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'secrets.delete_secret'
cls = Secret
default_redirect_url = 'secrets:secret_list'
filter = filters.SecretFilter
default_return_url = 'secrets:secret_list'

View File

@@ -37,6 +37,11 @@
<li><a href="{% url 'dcim:site_import' %}"><i class="fa fa-download" aria-hidden="true"></i> Import Sites</a></li>
{% endif %}
<li class="divider"></li>
<li><a href="{% url 'dcim:region_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Regions</a></li>
{% if perms.dcim.add_region %}
<li><a href="{% url 'dcim:region_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Region</a></li>
{% endif %}
<li class="divider"></li>
<li><a href="{% url 'tenancy:tenant_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Tenants</a></li>
{% if perms.tenancy.add_tenant %}
<li><a href="{% url 'tenancy:tenant_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Tenant</a></li>
@@ -296,6 +301,9 @@
</div>
</div>
</footer>
<script type="text/javascript">
var netbox_api_path = "/{{ settings.BASE_PATH }}api/";
</script>
<script src="{% static 'js/jquery-2.1.4.min.js' %}"></script>
<script src="{% static 'jquery-ui-1.11.4/jquery-ui.min.js' %}"></script>
<script src="{% static 'bootstrap-3.3.6-dist/js/bootstrap.min.js' %}"></script>

View File

@@ -66,6 +66,10 @@
<td>Tenant</td>
<td>
{% if circuit.tenant %}
{% if circuit.tenant.group %}
<a href="{{ circuit.tenant.group.get_absolute_url }}">{{ circuit.tenant.group.name }}</a>
<i class="fa fa-angle-right"></i>
{% endif %}
<a href="{{ circuit.tenant.get_absolute_url }}">{{ circuit.tenant }}</a>
{% else %}
<span class="text-muted">None</span>
@@ -92,6 +96,16 @@
{% endif %}
</td>
</tr>
<tr>
<td>Description</td>
<td>
{% if circuit.description %}
{{ circuit.description }}
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
</tr>
</table>
</div>
{% with circuit.get_custom_fields as custom_fields %}

View File

@@ -11,6 +11,7 @@
{% render_field form.tenant %}
{% render_field form.install_date %}
{% render_field form.commit_rate %}
{% render_field form.description %}
</div>
</div>
{% if form.custom_fields %}

View File

@@ -13,7 +13,7 @@
{% render_form form %}
<div class="form-group">
<button type="submit" class="btn btn-primary">Submit</button>
<a href="{% url obj_list_url %}" class="btn btn-default">Cancel</a>
<a href="{% url return_url %}" class="btn btn-default">Cancel</a>
</div>
</form>
</div>
@@ -58,10 +58,15 @@
<td>Commited rate in Kbps (optional)</td>
<td>2000</td>
</tr>
<tr>
<td>Description</td>
<td>Short description (optional)</td>
<td>Primary for voice</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>IC-603122,TeliaSonera,Transit,Strickland Propane,2016-02-23,2000</pre>
<pre>IC-603122,TeliaSonera,Transit,Strickland Propane,2016-02-23,2000,Primary for voice</pre>
</div>
</div>
{% endblock %}

View File

@@ -24,7 +24,6 @@
</div>
<div class="col-md-3">
{% include 'inc/search_panel.html' %}
{% include 'inc/filter_panel.html' %}
</div>
</div>
{% endblock %}

View File

@@ -83,7 +83,7 @@
{% else %}
<button type="submit" name="_create" class="btn btn-primary">Create</button>
{% endif %}
<a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
</div>
</div>
</form>

View File

@@ -15,7 +15,7 @@
</a>
{% endif %}
{% if termination and perms.circuits.delete_circuittermination %}
<a href="{% url 'circuits:circuittermination_delete' pk=termination.pk %}" class="btn btn-xs btn-danger">
<a href="{% url 'circuits:circuittermination_delete' pk=termination.pk %}?return_url={{ circuit.get_absolute_url }}" class="btn btn-xs btn-danger">
<span class="fa fa-trash" aria-hidden="true"></span> Delete
</a>
{% endif %}
@@ -27,6 +27,10 @@
<tr>
<td>Site</td>
<td>
{% if termination.site.region %}
<a href="{{ termination.site.region.get_absolute_url }}">{{ termination.site.region }}</a>
<i class="fa fa-angle-right"></i>
{% endif %}
<a href="{% url 'dcim:site' slug=termination.site.slug %}">{{ termination.site }}</a>
</td>
</tr>
@@ -34,7 +38,8 @@
<td>Termination</td>
<td>
{% if termination.interface %}
<span><a href="{% url 'dcim:device' pk=termination.interface.device.pk %}">{{ termination.interface.device }}</a> {{ termination.interface }}</span>
<a href="{% url 'dcim:device' pk=termination.interface.device.pk %}">{{ termination.interface.device }}</a>
<i class="fa fa-angle-right"></i> {{ termination.interface }}
{% else %}
<span class="text-muted">Not defined</span>
{% endif %}

View File

@@ -48,7 +48,7 @@
<h1>{{ provider }}</h1>
{% include 'inc/created_updated.html' with obj=provider %}
<div class="row">
<div class="col-md-6">
<div class="col-md-4">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Provider</strong>
@@ -104,6 +104,12 @@
{% endif %}
</td>
</tr>
<tr>
<td>Circuits</td>
<td>
<a href="{% url 'circuits:circuit_list' %}?provider={{ provider.slug }}">{{ provider.circuits.count }}</a>
</td>
</tr>
</table>
</div>
{% with provider.get_custom_fields as custom_fields %}
@@ -122,12 +128,20 @@
</div>
</div>
</div>
<div class="col-md-6">
<div class="col-md-8">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Circuits</strong>
</div>
<table class="table table-hover panel-body">
<tr>
<th>Circuit ID</th>
<th>Type</th>
<th>Tenant</th>
<th>A Side</th>
<th>Z Side</th>
<th>Description</th>
</tr>
{% for c in circuits %}
<tr>
<td>
@@ -136,6 +150,34 @@
<td>
<a href="{% url 'circuits:circuit_list' %}?type={{ c.type.slug }}">{{ c.type }}</a>
</td>
<td>
{% if c.tenant %}
<a href="{% url 'tenancy:tenant' slug=c.tenant.slug %}">{{ c.tenant }}</a>
{% else %}
<span class="text-muted">&mdash;</span>
{% endif %}
</td>
<td>
{% if c.termination_a %}
<a href="{% url 'dcim:site' slug=c.termination_a.site.slug %}">{{ c.termination_a.site }}</a>
{% else %}
<span class="text-muted">&mdash;</span>
{% endif %}
</td>
<td>
{% if c.termination_z %}
<a href="{% url 'dcim:site' slug=c.termination_z.site.slug %}">{{ c.termination_z.site }}</a>
{% else %}
<span class="text-muted">&mdash;</span>
{% endif %}
</td>
<td>
{% if c.description %}
{{ c.description }}
{% else %}
<span class="text-muted">&mdash;</span>
{% endif %}
</td>
</tr>
{% empty %}
<tr>

View File

@@ -13,7 +13,7 @@
{% render_form form %}
<div class="form-group">
<button type="submit" class="btn btn-primary">Submit</button>
<a href="{% url obj_list_url %}" class="btn btn-default">Cancel</a>
<a href="{% url return_url %}" class="btn btn-default">Cancel</a>
</div>
</form>
</div>

View File

@@ -23,7 +23,6 @@
</div>
<div class="col-md-3">
{% include 'inc/search_panel.html' %}
{% include 'inc/filter_panel.html' %}
</div>
</div>
{% endblock %}

View File

@@ -19,7 +19,7 @@
{% render_table table 'table.html' %}
</div>
<div class="col-md-3">
{% include 'inc/filter_panel.html' %}
{% include 'inc/search_panel.html' %}
</div>
</div>
{% endblock %}

View File

@@ -7,6 +7,9 @@
{% block content %}
<form action="." method="post" class="form form-horizontal">
{% csrf_token %}
{% for field in form.hidden_fields %}
{{ field }}
{% endfor %}
<div class="row">
<div class="col-md-6 col-md-offset-3">
{% if form.non_field_errors %}
@@ -29,6 +32,12 @@
{% render_field form.livesearch %}
</div>
<div class="tab-pane" id="select">
<div class="form-group">
<label class="col-md-3 control-label">Site</label>
<div class="col-md-9">
<p class="form-control-static">{{ consoleport.device.site }}</p>
</div>
</div>
{% render_field form.rack %}
{% render_field form.console_server %}
</div>
@@ -40,7 +49,7 @@
<div class="form-group">
<div class="col-md-9 col-md-offset-3">
<button type="submit" name="_update" class="btn btn-primary">Connect</button>
<a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
</div>
</div>
</div>

View File

@@ -6,7 +6,10 @@
{% block content %}
<form action="." method="post" class="form form-horizontal">
{% csrf_token %}
{% csrf_token %}
{% for field in form.hidden_fields %}
{{ field }}
{% endfor %}
<div class="row">
<div class="col-md-6 col-md-offset-3">
{% if form.non_field_errors %}
@@ -29,6 +32,12 @@
{% render_field form.livesearch %}
</div>
<div class="tab-pane" id="select">
<div class="form-group">
<label class="col-md-3 control-label">Site</label>
<div class="col-md-9">
<p class="form-control-static">{{ consoleserverport.device.site }}</p>
</div>
</div>
{% render_field form.rack %}
{% render_field form.device %}
</div>
@@ -40,7 +49,7 @@
<div class="form-group">
<div class="col-md-9 col-md-offset-3">
<button type="submit" name="_update" class="btn btn-primary">Connect</button>
<a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
</div>
</div>
</div>

View File

@@ -6,7 +6,7 @@
{% block title %}{{ device }}{% endblock %}
{% block content %}
{% include 'dcim/inc/_device_header.html' with active_tab='info' %}
{% include 'dcim/inc/device_header.html' with active_tab='info' %}
<div class="row">
<div class="col-md-5 col-lg-6">
<div class="panel panel-default">
@@ -14,26 +14,28 @@
<strong>Device</strong>
</div>
<table class="table table-hover panel-body attr-table">
<tr>
<td>Tenant</td>
<td>
{% if device.tenant %}
<a href="{{ device.tenant.get_absolute_url }}">{{ device.tenant }}</a>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</td>
</tr>
<tr>
<td>Site</td>
<td>
<a href="{% url 'dcim:site' slug=device.rack.site.slug %}">{{ device.rack.site }}</a>
{% if device.site.region %}
<a href="{{ device.site.region.get_absolute_url }}">{{ device.site.region }}</a>
<i class="fa fa-angle-right"></i>
{% endif %}
<a href="{% url 'dcim:site' slug=device.site.slug %}">{{ device.site }}</a>
</td>
</tr>
<tr>
<td>Rack</td>
<td>
<span><a href="{% url 'dcim:rack' pk=device.rack.pk %}">{{ device.rack.name }}</a>{% if device.rack.facility_id %} ({{ device.rack.facility_id }}){% endif %}</span>
{% if device.rack %}
{% if device.rack.group %}
<a href="{{ device.rack.group.get_absolute_url }}">{{ device.rack.group.name }}</a>
<i class="fa fa-angle-right"></i>
{% endif %}
<a href="{% url 'dcim:rack' pk=device.rack.pk %}">{{ device.rack.name }}</a>{% if device.rack.facility_id %} ({{ device.rack.facility_id }}){% endif %}
{% else %}
<span class="text-muted">None</span>
{% endif %}
</td>
</tr>
<tr>
@@ -44,15 +46,29 @@
<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 %}
{% elif device.rack and device.position %}
<span>U{{ device.position }} / {{ device.get_face_display }}</span>
{% elif device.device_type.u_height %}
{% elif device.rack and device.device_type.u_height %}
<span class="label label-warning">Not racked</span>
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
</tr>
<tr>
<td>Tenant</td>
<td>
{% if device.tenant %}
{% if device.tenant.group %}
<a href="{{ device.tenant.group.get_absolute_url }}">{{ device.tenant.group.name }}</a>
<i class="fa fa-angle-right"></i>
{% endif %}
<a href="{{ device.tenant.get_absolute_url }}">{{ device.tenant }}</a>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</td>
</tr>
<tr>
<td>Device Type</td>
<td>
@@ -183,7 +199,7 @@
{% if ip_addresses %}
<table class="table table-hover panel-body">
{% for ip in ip_addresses %}
{% include 'dcim/inc/_ipaddress.html' %}
{% include 'dcim/inc/ipaddress.html' %}
{% endfor %}
</table>
{% elif interfaces or mgmt_interfaces %}
@@ -212,7 +228,7 @@
{% if services %}
<table class="table table-hover panel-body">
{% for service in services %}
{% include 'dcim/inc/_service.html' %}
{% include 'dcim/inc/service.html' %}
{% endfor %}
</table>
{% else %}
@@ -234,40 +250,46 @@
</div>
<table class="table table-hover panel-body">
{% for iface in mgmt_interfaces %}
{% include 'dcim/inc/_interface.html' with icon='wrench' %}
{% include 'dcim/inc/interface.html' with icon='wrench' %}
{% empty %}
<tr>
<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>
{% endif %}
</td>
</tr>
{% if device.device_type.interface_templates.exists %}
<tr>
<td colspan="6" 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>
{% endif %}
</td>
</tr>
{% endif %}
{% endfor %}
{% for cp in console_ports %}
{% include 'dcim/inc/_consoleport.html' %}
{% include 'dcim/inc/consoleport.html' %}
{% empty %}
<tr>
<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>
{% endif %}
</td>
</tr>
{% if device.device_type.console_port_templates.exists %}
<tr>
<td colspan="6" 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>
{% endif %}
</td>
</tr>
{% endif %}
{% endfor %}
{% for pp in power_ports %}
{% include 'dcim/inc/_powerport.html' %}
{% include 'dcim/inc/powerport.html' %}
{% empty %}
<tr>
<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>
{% endif %}
</td>
</tr>
{% if device.device_type.power_port_templates.exists %}
<tr>
<td colspan="6" 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>
{% endif %}
</td>
</tr>
{% endif %}
{% endfor %}
</table>
{% if perms.dcim.add_interface or perms.dcim.add_consoleport or perms.dcim.add_powerport %}
@@ -314,7 +336,11 @@
<a href="{% url 'dcim:device' pk=rd.pk %}">{{ rd }}</a>
</td>
<td>
<a href="{% url 'dcim:rack' pk=rd.rack.pk %}">Rack {{ rd.rack }}</a>
{% if rd.rack %}
<a href="{% url 'dcim:rack' pk=rd.rack.pk %}">Rack {{ rd.rack }}</a>
{% else %}
<span class="text-muted">&mdash;</span>
{% endif %}
</td>
<td>{{ rd.device_type.full_name }}</td>
</tr>
@@ -349,7 +375,7 @@
</div>
<table class="table table-hover panel-body">
{% for devicebay in device_bays %}
{% include 'dcim/inc/_devicebay.html' with selectable=True %}
{% include 'dcim/inc/devicebay.html' with selectable=True %}
{% empty %}
<tr>
<td colspan="4">No device bays defined</td>
@@ -379,9 +405,10 @@
{% endif %}
{% endif %}
{% if interfaces or device.device_type.is_network_device %}
{% if perms.dcim.delete_interface %}
{% if perms.dcim.change_interface or perms.dcim.delete_interface %}
<form method="post">
{% csrf_token %}
<input type="hidden" name="device" value="{{ device.pk }}" />
{% endif %}
<div class="panel panel-default">
<div class="panel-heading">
@@ -401,7 +428,7 @@
</div>
<table class="table table-hover panel-body">
{% for iface in interfaces %}
{% include 'dcim/inc/_interface.html' with selectable=True %}
{% include 'dcim/inc/interface.html' with selectable=True %}
{% empty %}
<tr>
<td colspan="4">No interfaces defined</td>
@@ -458,7 +485,7 @@
</div>
<table class="table table-hover panel-body">
{% for csp in cs_ports %}
{% include 'dcim/inc/_consoleserverport.html' with selectable=True %}
{% include 'dcim/inc/consoleserverport.html' with selectable=True %}
{% empty %}
<tr>
<td colspan="4">No console server ports defined</td>
@@ -510,7 +537,7 @@
</div>
<table class="table table-hover panel-body">
{% for po in power_outlets %}
{% include 'dcim/inc/_poweroutlet.html' with selectable=True %}
{% include 'dcim/inc/poweroutlet.html' with selectable=True %}
{% empty %}
<tr>
<td colspan="4">No power outlets defined</td>
@@ -548,9 +575,10 @@
{% block javascript %}
<script type="text/javascript">
function toggleConnection(elem, api_url) {
var url = netbox_api_path + api_url + elem.attr('data') + "/";
if (elem.hasClass('connected')) {
$.ajax({
url: api_url + elem.attr('data') + "/",
url: url,
method: 'PATCH',
dataType: 'json',
beforeSend: function(xhr, settings) {
@@ -569,7 +597,7 @@ function toggleConnection(elem, api_url) {
});
} else {
$.ajax({
url: api_url + elem.attr('data') + "/",
url: url,
method: 'PATCH',
dataType: 'json',
beforeSend: function(xhr, settings) {
@@ -590,13 +618,13 @@ function toggleConnection(elem, api_url) {
return false;
}
$(".consoleport-toggle").click(function() {
return toggleConnection($(this), "/{{ settings.BASE_PATH }}api/dcim/console-ports/");
return toggleConnection($(this), "dcim/console-ports/");
});
$(".powerport-toggle").click(function() {
return toggleConnection($(this), "/{{ settings.BASE_PATH }}api/dcim/power-ports/");
return toggleConnection($(this), "dcim/power-ports/");
});
$(".interface-toggle").click(function() {
return toggleConnection($(this), "/{{ settings.BASE_PATH }}api/dcim/interface-connections/");
return toggleConnection($(this), "dcim/interface-connections/");
});
</script>
<script src="{% static 'js/graphs.js' %}"></script>

View File

@@ -5,8 +5,8 @@
<h1>Add {{ component_name|title }}</h1>
<form action="." method="post" class="form form-horizontal">
{% csrf_token %}
{% if request.POST.redirect_url %}
<input type="hidden" name="redirect_url" value="{{ request.POST.redirect_url }}" />
{% if request.POST.return_url %}
<input type="hidden" name="return_url" value="{{ request.POST.return_url }}" />
{% endif %}
{% for field in form.hidden_fields %}
{{ field }}
@@ -51,7 +51,7 @@
<div class="form-group text-right">
<div class="col-md-12">
<button type="submit" name="_create" class="btn btn-primary">Create</button>
<a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
</div>
</div>
</div>

View File

@@ -3,7 +3,7 @@
{% block title %}Create {{ component_type }} ({{ parent }}){% endblock %}
{% block content %}{{ form.errors }}
{% block content %}
<form action="." method="post" class="form form-horizontal">
{% csrf_token %}
<div class="row">
@@ -34,7 +34,7 @@
<div class="col-md-9 col-md-offset-3">
<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>
<a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
</div>
</div>
</div>

View File

@@ -5,7 +5,7 @@
{% block title %}Device Import{% endblock %}
{% block content %}
{% include 'dcim/inc/_device_import_header.html' %}
{% include 'dcim/inc/device_import_header.html' %}
<div class="row">
<div class="col-md-12">
<form action="." method="post" class="form">
@@ -13,7 +13,7 @@
{% render_form form %}
<div class="form-group">
<button type="submit" class="btn btn-primary">Submit</button>
<a href="{% url obj_list_url %}" class="btn btn-default">Cancel</a>
<a href="{% url return_url %}" class="btn btn-default">Cancel</a>
</div>
</form>
<h4>CSV Format</h4>

View File

@@ -5,7 +5,7 @@
{% block title %}Device Import{% endblock %}
{% block content %}
{% include 'dcim/inc/_device_import_header.html' with active_tab='child_import' %}
{% include 'dcim/inc/device_import_header.html' with active_tab='child_import' %}
<div class="row">
<div class="col-md-12">
<form action="." method="post" class="form">
@@ -13,7 +13,7 @@
{% render_form form %}
<div class="form-group">
<button type="submit" class="btn btn-primary">Submit</button>
<a href="{% url obj_list_url %}" class="btn btn-default">Cancel</a>
<a href="{% url return_url %}" class="btn btn-default">Cancel</a>
</div>
</form>
<h4>CSV Format</h4>

View File

@@ -3,7 +3,7 @@
{% block title %}{{ device }} - Inventory{% endblock %}
{% block content %}
{% include 'dcim/inc/_device_header.html' with active_tab='inventory' %}
{% include 'dcim/inc/device_header.html' with active_tab='inventory' %}
<div class="row">
<div class="col-md-4">
<div class="panel panel-default">
@@ -67,7 +67,7 @@
<a href="{% url 'dcim:module_edit' pk=m.pk %}" class="btn btn-xs btn-warning"><span class="glyphicon glyphicon-pencil" aria-hidden="true"></span></a>
{% endif %}
{% if perms.dcim.delete_module %}
<a href="{% url 'dcim:module_delete' pk=m.pk %}" class="btn btn-xs btn-danger"><span class="glyphicon glyphicon-trash" aria-hidden="true"></span></a>
<a href="{% url 'dcim:module_delete' pk=m.pk %}?return_url={% url 'dcim:device_inventory' pk=device.pk %}" class="btn btn-xs btn-danger"><span class="glyphicon glyphicon-trash" aria-hidden="true"></span></a>
{% endif %}
</td>
</tr>
@@ -80,10 +80,10 @@
<td>{{ m2.serial }}</td>
<td class="text-right">
{% if perms.dcim.change_module %}
<a href="{% url 'dcim:module_edit' pk=m.pk %}" class="btn btn-xs btn-warning"><span class="glyphicon glyphicon-pencil" aria-hidden="true"></span></a>
<a href="{% url 'dcim:module_edit' pk=m2.pk %}" class="btn btn-xs btn-warning"><span class="glyphicon glyphicon-pencil" aria-hidden="true"></span></a>
{% endif %}
{% if perms.dcim.delete_module %}
<a href="{% url 'dcim:module_delete' pk=m.pk %}" class="btn btn-xs btn-danger"><span class="glyphicon glyphicon-trash" aria-hidden="true"></span></a>
<a href="{% url 'dcim:module_delete' pk=m2.pk %}?return_url={% url 'dcim:device_inventory' pk=device.pk %}" class="btn btn-xs btn-danger"><span class="glyphicon glyphicon-trash" aria-hidden="true"></span></a>
{% endif %}
</td>
</tr>
@@ -96,10 +96,10 @@
<td>{{ m3.serial }}</td>
<td class="text-right">
{% if perms.dcim.change_module %}
<a href="{% url 'dcim:module_edit' pk=m.pk %}" class="btn btn-xs btn-warning"><span class="glyphicon glyphicon-pencil" aria-hidden="true"></span></a>
<a href="{% url 'dcim:module_edit' pk=m3.pk %}" class="btn btn-xs btn-warning"><span class="glyphicon glyphicon-pencil" aria-hidden="true"></span></a>
{% endif %}
{% if perms.dcim.delete_module %}
<a href="{% url 'dcim:module_delete' pk=m.pk %}" class="btn btn-xs btn-danger"><span class="glyphicon glyphicon-trash" aria-hidden="true"></span></a>
<a href="{% url 'dcim:module_delete' pk=m3.pk %}?return_url={% url 'dcim:device_inventory' pk=device.pk %}" class="btn btn-xs btn-danger"><span class="glyphicon glyphicon-trash" aria-hidden="true"></span></a>
{% endif %}
</td>
</tr>
@@ -112,10 +112,10 @@
<td>{{ m4.serial }}</td>
<td class="text-right">
{% if perms.dcim.change_module %}
<a href="{% url 'dcim:module_edit' pk=m.pk %}" class="btn btn-xs btn-warning"><span class="glyphicon glyphicon-pencil" aria-hidden="true"></span></a>
<a href="{% url 'dcim:module_edit' pk=m4.pk %}" class="btn btn-xs btn-warning"><span class="glyphicon glyphicon-pencil" aria-hidden="true"></span></a>
{% endif %}
{% if perms.dcim.delete_module %}
<a href="{% url 'dcim:module_delete' pk=m.pk %}" class="btn btn-xs btn-danger"><span class="glyphicon glyphicon-trash" aria-hidden="true"></span></a>
<a href="{% url 'dcim:module_delete' pk=m4.pk %}?return_url={% url 'dcim:device_inventory' pk=device.pk %}" class="btn btn-xs btn-danger"><span class="glyphicon glyphicon-trash" aria-hidden="true"></span></a>
{% endif %}
</td>
</tr>

View File

@@ -24,7 +24,31 @@
</div>
<div class="col-md-3">
{% include 'inc/search_panel.html' %}
{% include 'inc/filter_panel.html' %}
</div>
</div>
{% endblock %}
{% block javascript %}
<script type="text/javascript">
$(document).ready(function() {
var model_list = $('#id_device_type_id');
$('#id_manufacturer_id').change(function() {
model_list.empty();
var selected_manufacturers = $(this).val();
if (selected_manufacturers) {
var api_url = netbox_api_path + 'dcim/device-types/?manufacturer_id=' + selected_manufacturers.join('&manufacturer_id=');
$.ajax({
url: api_url,
dataType: 'json',
success: function (response, status) {
$.each(response, function (index, device_type) {
var option = $("<option></option>").attr("value", device_type.id).text(device_type["model"] + " (" + device_type["instance_count"] + ")");
model_list.append(option);
});
}
});
}
});
});
</script>
{% endblock %}

View File

@@ -3,7 +3,7 @@
{% block title %}{{ device }} - LLDP Neighbors{% endblock %}
{% block content %}
{% include 'dcim/inc/_device_header.html' with active_tab='lldp-neighbors' %}
{% include 'dcim/inc/device_header.html' with active_tab='lldp-neighbors' %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>LLDP Neighbors</strong>

View File

@@ -37,7 +37,7 @@
<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>
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
</div>
</div>
</div>

View File

@@ -72,6 +72,10 @@
{% endif %}
</td>
</tr>
<tr>
<td>Interface Ordering</td>
<td>{{ devicetype.get_interface_ordering_display }}</td>
</tr>
<tr>
<td>Instances</td>
<td><a href="{% url 'dcim:device_list' %}?device_type_id={{ devicetype.pk }}">{{ devicetype.instances.count }}</a></td>

View File

@@ -33,7 +33,7 @@
<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>
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
</div>
</div>
</div>

View File

@@ -11,6 +11,12 @@
{% render_field form.part_number %}
{% render_field form.u_height %}
{% render_field form.is_full_depth %}
{% render_field form.interface_ordering %}
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Function</strong></div>
<div class="panel-body">
{% render_field form.is_console_server %}
{% render_field form.is_pdu %}
{% render_field form.is_network_device %}

View File

@@ -19,7 +19,6 @@
</div>
<div class="col-md-3">
{% include 'inc/search_panel.html' %}
{% include 'inc/filter_panel.html' %}
</div>
</div>
{% endblock %}

View File

@@ -1,5 +1,5 @@
<tr{% if cp.cs_port and not cp.connection_status %} class="info"{% endif %}>
{% if selectable and perms.dcim.delete_consoleport %}
{% if selectable and perms.dcim.change_consoleport or perms.dcim.delete_consoleport %}
<td class="pk">
<input name="pk" type="checkbox" value="{{ cp.pk }}" />
</td>

View File

@@ -1,5 +1,5 @@
<tr{% if csp.connected_console and not csp.connected_console.connection_status %} class="info"{% endif %}>
{% if selectable and perms.dcim.delete_consoleserverport %}
{% if selectable and perms.dcim.change_consoleserverport or perms.dcim.delete_consoleserverport %}
<td class="pk">
<input name="pk" type="checkbox" value="{{ csp.pk }}" />
</td>

View File

@@ -1,17 +1,17 @@
<div class="row">
<div class="col-sm-8 col-md-9">
{% if device.rack %}
<ol class="breadcrumb">
<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>
<ol class="breadcrumb">
<li><a href="{% url 'dcim:site' slug=device.site.slug %}">{{ device.site }}</a></li>
{% if device.rack %}
<li><a href="{% url 'dcim:rack_list' %}?site={{ device.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 %}
{% endif %}
{% 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>
</div>
<div class="col-sm-4 col-md-3">
<form action="{% url 'dcim:device_list' %}" method="get">

View File

@@ -7,12 +7,12 @@
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add Components <span class="caret"></span>
</button>
<ul class="dropdown-menu">
{% if perms.dcim.add_consoleport %}<li><a href="{% url 'dcim:device_bulk_add_consoleport' %}" class="formaction">Console Ports</a></li>{% endif %}
{% if perms.dcim.add_consoleserverport %}<li><a href="{% url 'dcim:device_bulk_add_consoleserverport' %}" class="formaction">Console Server Ports</a></li>{% endif %}
{% if perms.dcim.add_powerport %}<li><a href="{% url 'dcim:device_bulk_add_powerport' %}" class="formaction">Power Ports</a></li>{% endif %}
{% if perms.dcim.add_poweroutlet %}<li><a href="{% url 'dcim:device_bulk_add_poweroutlet' %}" class="formaction">Power Outlets</a></li>{% endif %}
{% if perms.dcim.add_interface %}<li><a href="{% url 'dcim:device_bulk_add_interface' %}" class="formaction">Interfaces</a></li>{% endif %}
{% if perms.dcim.add_devicebay %}<li><a href="{% url 'dcim:device_bulk_add_devicebay' %}" class="formaction">Device Bays</a></li>{% endif %}
{% if perms.dcim.add_consoleport %}<li><a href="{% url 'dcim:device_bulk_add_consoleport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Console Ports</a></li>{% endif %}
{% if perms.dcim.add_consoleserverport %}<li><a href="{% url 'dcim:device_bulk_add_consoleserverport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Console Server Ports</a></li>{% endif %}
{% if perms.dcim.add_powerport %}<li><a href="{% url 'dcim:device_bulk_add_powerport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Power Ports</a></li>{% endif %}
{% if perms.dcim.add_poweroutlet %}<li><a href="{% url 'dcim:device_bulk_add_poweroutlet' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Power Outlets</a></li>{% endif %}
{% if perms.dcim.add_interface %}<li><a href="{% url 'dcim:device_bulk_add_interface' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Interfaces</a></li>{% endif %}
{% if perms.dcim.add_devicebay %}<li><a href="{% url 'dcim:device_bulk_add_devicebay' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Device Bays</a></li>{% endif %}
</ul>
</div>
{% endif %}

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