Compare commits

..

151 Commits

Author SHA1 Message Date
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
Jeremy Stretch
23f6832d9c Merge pull request #774 from digitalocean/develop
Release v1.8.1
2017-01-04 15:30:54 -05:00
Jeremy Stretch
bce23ebdf5 Release v1.8.1 2017-01-04 15:28:53 -05:00
Jeremy Stretch
0d4b2a6e92 Fixes #772: Fix TypeError in API RackUnitListView when no device is excluded 2017-01-04 10:58:11 -05:00
Jeremy Stretch
52567c4ade Fixes #764: Encapsulate in double quotes values containing commas when exporting to CSV 2017-01-04 10:47:00 -05:00
Jeremy Stretch
8154ae3685 Closes #771: Don't automatically redirect user when only one object is returned in a list 2017-01-04 09:51:40 -05:00
Jeremy Stretch
7f297b4733 Fixes #769: Show default value for boolean custom fields 2017-01-04 09:47:26 -05:00
Jeremy Stretch
96451bfe9e Fixes #767: Fixes xconnect_id error when searching for ciruits 2017-01-03 17:00:43 -05:00
Jeremy Stretch
921b08d0c9 Allow null filtering of prefix VLAN 2017-01-03 16:57:42 -05:00
Jeremy Stretch
6eff95a2b1 Post-release version bump 2017-01-03 15:14:09 -05:00
Jeremy Stretch
88dace75a1 Merge pull request #766 from digitalocean/develop
Release v1.8.0
2017-01-03 15:13:36 -05:00
Jeremy Stretch
f8bced34eb Release v1.8.0 2017-01-03 15:07:41 -05:00
Jeremy Stretch
cf64ef342f Fixes #763: Added missing fields to CSV exports 2017-01-03 14:52:56 -05:00
Jeremy Stretch
c7acc9ad69 Updated circuit import template 2017-01-03 14:25:51 -05:00
Jeremy Stretch
31e8986e35 Minor docs tweaks 2017-01-03 14:03:50 -05:00
Jeremy Stretch
050b6449d4 Split site contact info into a separate panel 2017-01-03 13:46:49 -05:00
Jeremy Stretch
49dd5761f8 Tweaked web server installation docs 2017-01-03 13:44:22 -05:00
Jeremy Stretch
5215779061 Fixes #757: Debug toolbar middleware must always be included, even if DEBUG is False 2016-12-29 13:45:57 -05:00
Jeremy Stretch
48e9cd6a00 Miscellaneous cleanup and documentation 2016-12-29 13:42:38 -05:00
Jeremy Stretch
e06bfffd60 Fixed outdated select_related reference to circuit 2016-12-29 11:53:24 -05:00
Jeremy Stretch
e7b08f8f2f Closes #756: Added contact details to site model 2016-12-29 11:37:40 -05:00
Jeremy Stretch
8edaff860c Fixes #658: Added is_pool field to Prefix model 2016-12-27 15:07:52 -05:00
Jeremy Stretch
d9d7068c5f Fixed bug introduced in 04fd197c9b 2016-12-27 14:18:31 -05:00
Jeremy Stretch
e647065e63 Improved device interface list performance 2016-12-27 13:21:19 -05:00
Jeremy Stretch
5716207ba6 Simplified paginator when dealing with <=5 pages 2016-12-26 15:43:48 -05:00
Jeremy Stretch
bdff71db9e Fixed paginator text rendering 2016-12-26 15:36:33 -05:00
Jeremy Stretch
9e670d318c Relaxed version requirements 2016-12-26 15:33:08 -05:00
Jeremy Stretch
1882d832c3 Bumped django-tables2 version 2016-12-26 14:34:13 -05:00
Jeremy Stretch
04fd197c9b Fixed table form rendering for django-tables2>=1.2.1 2016-12-26 14:30:56 -05:00
Jeremy Stretch
edb8904474 Fixed debug toolbar display 2016-12-26 12:15:14 -05:00
Jeremy Stretch
a5fe4468d0 Upgraded django-filter to 0.15.3 2016-12-26 11:58:27 -05:00
Jeremy Stretch
65d8bb8c26 Bumped Markdown version 2016-12-26 11:05:46 -05:00
Jeremy Stretch
cf796fb40f Fixes #751: Relax version constraint on python-cryptography 2016-12-26 10:55:14 -05:00
Jeremy Stretch
0ac3e91e3b Updated middleware for Django 1.10 2016-12-26 10:48:15 -05:00
Jeremy Stretch
e8684240a7 Added permissions evaluation 2016-12-21 17:28:35 -05:00
Jeremy Stretch
c1b6da771f Only display "select all" button if there are two or more items 2016-12-21 17:26:25 -05:00
Jeremy Stretch
3de51876d0 Refactored device component creation views 2016-12-21 17:20:27 -05:00
Jeremy Stretch
0e4d02bd10 Renamed template 2016-12-21 15:31:55 -05:00
Jeremy Stretch
7b06f5e9fc Introduced DeviceComponentCreateView 2016-12-21 15:26:56 -05:00
Jeremy Stretch
37b2ff02e7 Standardized inheritance order of BootstrapMixin 2016-12-21 14:15:18 -05:00
Jeremy Stretch
1ed5389703 Fixed device component bulk creation permissions 2016-12-21 13:52:16 -05:00
Jeremy Stretch
b6da5ce6bd Fixed device type component creation permissions 2016-12-21 13:47:15 -05:00
Jeremy Stretch
ae8f40ed8d Fixes #563: Allow a device to be flipped from one rack face to the other 2016-12-21 11:06:42 -05:00
Jeremy Stretch
96de61ddfb Closes #716: Add ASN field to site bulk edit form 2016-12-20 16:13:45 -05:00
Jeremy Stretch
9fd9719d0b Closes #181: Implemented support for bulk IP address creation 2016-12-20 15:39:22 -05:00
Jeremy Stretch
f0d8e02d63 Fixed prefix/VLAN role links 2016-12-19 14:45:25 -05:00
Jeremy Stretch
44d5ff26a4 Fixes #747: Fixes natural_order_by integer cast error on large numbers 2016-12-19 11:01:44 -05:00
Jeremy Stretch
550efcb640 Tweaked prefix column header padding 2016-12-16 17:09:27 -05:00
Jeremy Stretch
15bec75167 Fixes #744: Fixed export of sites without an AS number 2016-12-16 16:33:07 -05:00
Jeremy Stretch
c94d111401 Closes #743: Enabled bulk creation of all device components 2016-12-16 16:29:32 -05:00
Jeremy Stretch
6f1532adac Fixed dcim tests 2016-12-16 12:15:32 -05:00
Jeremy Stretch
b7fe220860 Converted module_add to ObjectEditView 2016-12-16 12:12:42 -05:00
Jeremy Stretch
b451ece057 Closes #122: Add comments field to device types 2016-12-16 11:14:44 -05:00
Jeremy Stretch
b56e37ad84 Closes #722: Enabled custom fields for device types 2016-12-16 10:54:45 -05:00
Jeremy Stretch
712567cabc Closes #613: Added prefixes column to VLAN list; added VLAN column to prefix list 2016-12-15 16:56:43 -05:00
Jeremy Stretch
017263f640 Fixes #741: Hide "select all" button for users without edit permissions 2016-12-15 15:58:50 -05:00
Jeremy Stretch
f02c222d4f Closes #539: Implemented L4 services for devices 2016-12-15 15:32:58 -05:00
Jeremy Stretch
66fa877198 ObjectEditView: Save many-to-many fields 2016-12-15 14:37:35 -05:00
Jeremy Stretch
6a9f26a68d Cleaned up attribute tables 2016-12-14 17:09:33 -05:00
Jeremy Stretch
bf817eb69e Closes #49: Introduction of circuit terminations 2016-12-14 13:47:22 -05:00
Jeremy Stretch
298ac1ba7a Widened page layout; improved mobile rendering 2016-12-09 16:23:11 -05:00
Jeremy Stretch
bd40f72ad5 #49: Allow selection of devices at other sites when connecting an interface 2016-12-09 11:43:50 -05:00
Jeremy Stretch
a0eff04185 Post-release version bump 2016-12-08 12:35:18 -05:00
Jeremy Stretch
8eb140fd65 Merge pull request #736 from digitalocean/develop
Release v1.7.3
2016-12-08 12:34:53 -05:00
Jeremy Stretch
a68e82575f Release v1.7.3 2016-12-08 12:33:36 -05:00
Jeremy Stretch
5035a9567b Fixes #729: Corrected cancellation links when editing secondary objects 2016-12-08 12:20:45 -05:00
Jeremy Stretch
d5095362d7 Fixes #734: Corrected display of device type when editing a device 2016-12-08 09:59:21 -05:00
Jeremy Stretch
3a6d7a1f7f #733: Fixed MAC address device filter 2016-12-07 15:53:19 -05:00
Jeremy Stretch
cc6ae8ebe4 Merge pull request #733 from linuxsimba/remove_mac_addr_required_from_filter
FIX: filtering devices fails because mac address filter is a required
2016-12-07 15:42:50 -05:00
stanley karunditu
b4940a64be FIX: filtering devices fails because mac address filter is a required
field
2016-12-07 15:30:57 -05:00
Jeremy Stretch
fca812928e #724: Exempt API views from LoginRequiredMiddleware to enable basic HTTP authentication when LOGIN_REQUIRED is true 2016-12-07 15:14:22 -05:00
Jeremy Stretch
4a9b4c5387 Fixes #732: Allow custom select field values to be deselected if the field is not required 2016-12-07 14:00:52 -05:00
Jeremy Stretch
1f09f3d096 Merge pull request #728 from digitalocean/develop
Release v1.7.2-r1
2016-12-06 15:38:52 -05:00
Jeremy Stretch
efb95937fc Reverting GitHub test 2016-12-06 15:32:11 -05:00
Jeremy Stretch
ce7ee1771a Testing GitHub 2016-12-06 15:31:31 -05:00
Jeremy Stretch
da216e2c22 Fixes #727: Corrected error in rack elevation display 2016-12-06 15:27:35 -05:00
Jeremy Stretch
e58ee4e0e3 Post-release version bump 2016-12-06 14:55:45 -05:00
Jeremy Stretch
66be85a41f Merge pull request #726 from digitalocean/develop
Release v1.7.2
2016-12-06 14:55:19 -05:00
Jeremy Stretch
2171dcee7f Release v1.7.2 2016-12-06 14:53:43 -05:00
Jeremy Stretch
3262262a8a Closes #663: Added MAC address search field to device list 2016-12-06 14:45:01 -05:00
Jeremy Stretch
28b586aca7 Fixes #723: API documentation is now accessible when using BASE_PATH 2016-12-06 14:08:25 -05:00
Jeremy Stretch
f007b0dbde Closes #695: Added is_private field to RIR 2016-12-06 13:59:13 -05:00
Jeremy Stretch
6e5950be77 Fixes #720: Display user action links properly in admin UI 2016-12-06 13:16:42 -05:00
Jeremy Stretch
eb4cd0e723 Fixes #672: Expanded color selection for rack and device roles 2016-12-06 12:28:29 -05:00
Jeremy Stretch
300ee820fa #672: Cleaned up rack elevation CSS 2016-12-05 18:11:07 -05:00
Jeremy Stretch
7d6d7942d9 Rewrote get_connected_interface() to return first connection if more than one exist (erroneously) 2016-12-02 16:09:07 -05:00
Jeremy Stretch
05debf7e40 Updated CONTRIBUTING to discourage the use of "+1" comments on issues 2016-12-01 15:16:04 -05:00
Jeremy Stretch
dc88cb5ac7 Fixes #718: Restore is_primary field on IP assignment form 2016-12-01 14:54:20 -05:00
Jeremy Stretch
b275009544 Fixed missing on command block 2016-11-30 12:10:46 -05:00
Jeremy Stretch
d960481adb Ditched syntax highlighting for shell commands 2016-11-30 12:07:51 -05:00
Jeremy Stretch
2986840755 Specified syntax for code blocks 2016-11-30 12:01:45 -05:00
Jeremy Stretch
9b8bae501b Fixes #677: Add cffi as an explicit dependency to avoid setuptools error on Debian 2016-11-30 11:19:28 -05:00
Jeremy Stretch
9ea3383fde #702: Fix lingering Unicode incompatibility 2016-11-29 17:33:22 -05:00
Jeremy Stretch
77ac79f32c Fixes #713: Include a label for the comments field when editing circuits, providers, or racks in bulk 2016-11-29 17:29:56 -05:00
Jeremy Stretch
e31fae5ec5 Fixes #712: Corrected export of tenants which are not assigned to a group 2016-11-29 13:45:31 -05:00
Jeremy Stretch
8bff8bcbe2 Fixes #702: Improved Unicode support for custom fields 2016-11-29 13:34:22 -05:00
Jeremy Stretch
cc79b1136b Fixes #696: Corrected link to VRF in prefix and IP address breadcrumbs 2016-11-18 09:49:04 -05:00
Jeremy Stretch
1af9ea9e2d Post-release version bump 2016-11-15 12:36:17 -05:00
Jeremy Stretch
814c11167e Merge pull request #694 from digitalocean/develop
Release v1.7.1
2016-11-15 12:34:09 -05:00
Jeremy Stretch
1d509a8ff8 Release v1.7.1 2016-11-15 12:13:42 -05:00
Jeremy Stretch
f2232a15d9 Merge pull request #689 from bemanuel/develop
Add Graphviz to Topology Maps
2016-11-14 12:10:13 -05:00
Jeremy Stretch
955abcef21 Fixes #691: Allow the assignment of power ports to PDUs 2016-11-14 11:29:03 -05:00
Jeremy Stretch
9eaf153673 Fixes #692: Form errors are not displayed on checkbox fields 2016-11-14 11:13:27 -05:00
Bruno Emanuel
8e71c0f2a8 Removed python-graphviz 2016-11-13 13:29:11 -03:00
Jeremy Stretch
18a516ee53 Closes #685: When assigning an IP to a device, automaitcally select the interface if only one exists 2016-11-11 15:29:40 -05:00
Jeremy Stretch
f5b2420b4b Merge pull request #686 from digitalocean/rir_stats
#667: Add utilization statistics to RIR list view
2016-11-11 15:13:41 -05:00
Jeremy Stretch
f569561997 Another PEP8 fix 2016-11-11 15:09:25 -05:00
Jeremy Stretch
99c2911a66 PEP8 fix 2016-11-11 15:04:14 -05:00
Jeremy Stretch
a0ee6b0d58 Closes #667: Added stats to RIR list view 2016-11-11 15:02:53 -05:00
Jeremy Stretch
d891c8c981 Incorporated stats into RIR list view 2016-11-11 12:45:24 -05:00
Jeremy Stretch
07e34fbe84 Fixes #678: Server error on device import specifying an invalid device type 2016-11-10 15:30:45 -05:00
Jeremy Stretch
7dfd32a5c4 Fixes #676: Server error when bulk editing device types 2016-11-10 15:15:55 -05:00
Jeremy Stretch
9c7f55d8d0 Fixes #674: Correct status assignment on IP address import 2016-11-10 15:01:05 -05:00
Bruno Emanuel
e496dc710f Add Graphviz to Topology Maps 2016-11-04 18:01:07 -03:00
Jeremy Stretch
13cdc44caf #667: Initial work on RIR statistics 2016-11-04 16:04:29 -04:00
Jeremy Stretch
1f3f9781d9 Post-release version bump 2016-11-03 15:13:15 -04:00
151 changed files with 3302 additions and 1837 deletions

View File

@@ -8,10 +8,9 @@ If you encounter any issues installing or using NetBox, try one of the following
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
### Mailing List
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).
We have established a Google Groups Mailing List for issues and general discussion. You can find us [here]( https://groups.google.com/forum/#!forum/netbox-discuss).
## Reporting Bugs
@@ -24,7 +23,7 @@ click "add a reaction" in the top right corner of the issue and add a thumbs up
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.
* If you haven't found an existing issue that describes your suspected bug, please inquire about it on IRC or Google Groups.
**Do not** file an issue until you have received confirmation that it is in fact a bug. Invalid issues are very
distracting and slow the pace at which NetBox is developed.
@@ -43,8 +42,9 @@ take some time for someone to address your issue.
* 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 (+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.
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.)
* 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:

View File

@@ -5,7 +5,7 @@ WORKDIR /opt/netbox
ARG BRANCH=master
ARG URL=https://github.com/digitalocean/netbox.git
RUN git clone --depth 1 $URL -b $BRANCH . && \
apt-get update -qq && apt-get install -y libldap2-dev libsasl2-dev libssl-dev && \
apt-get update -qq && apt-get install -y libldap2-dev libsasl2-dev libssl-dev graphviz && \
pip install gunicorn==17.5 && \
pip install django-auth-ldap && \
pip install -r requirements.txt

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

@@ -4,29 +4,30 @@ The circuits component of NetBox deals with the management of long-haul Internet
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.
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:
* 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 can be tied to a site, or to a specific device and interface within that site. Each termination can be assigned a separate downstream and upstream speed independent from one another. Fields are also available to track cross-connect and patch panel details.
!!! 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

@@ -86,3 +86,9 @@ One IP address can be designated as the network address translation (NAT) IP add
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.
Like prefixes, each VLAN is assigned an operational status and (optionally) a functional role.
---
# 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.)

View File

@@ -4,10 +4,10 @@ This guide demonstrates how to build and run NetBox as a Docker container. It as
To get NetBox up and running:
```
git clone -b master https://github.com/digitalocean/netbox.git
cd netbox
docker-compose up -d
```no-highlight
# git clone -b master https://github.com/digitalocean/netbox.git
# cd netbox
# docker-compose up -d
```
The application will be available on http://localhost/ after a few minutes.

View File

@@ -7,19 +7,19 @@ built-in Django users in the event of a failure.
On Ubuntu:
```
```no-highlight
sudo apt-get install -y python-dev libldap2-dev libsasl2-dev libssl-dev
```
On CentOS:
```
```no-highlight
sudo yum install -y python-devel openldap-devel
```
## Install django-auth-ldap
```
```no-highlight
sudo pip install django-auth-ldap
```

View File

@@ -2,13 +2,13 @@
**Debian/Ubuntu**
```
```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**
```
```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
```
@@ -19,7 +19,7 @@ You may opt to install NetBox either from a numbered release or by cloning the m
Download the [latest stable release](https://github.com/digitalocean/netbox/releases) from GitHub as a tarball or ZIP archive and extract it to your desired path. In this example, we'll use `/opt/netbox`.
```
```no-highlight
# wget https://github.com/digitalocean/netbox/archive/vX.Y.Z.tar.gz
# tar -xzf vX.Y.Z.tar.gz -C /opt
# cd /opt/
@@ -31,28 +31,27 @@ Download the [latest stable release](https://github.com/digitalocean/netbox/rele
Create the base directory for the NetBox installation. For this guide, we'll use `/opt/netbox`.
```
# mkdir -p /opt/netbox/
# cd /opt/netbox/
```no-highlight
# mkdir -p /opt/netbox/ && cd /opt/netbox/
```
If `git` is not already installed, install it:
**Debian/Ubuntu**
```
```no-highlight
# apt-get install -y git
```
**CentOS/RHEL**
```
```no-highlight
# yum install -y git
```
Next, clone the **master** branch of the NetBox GitHub repository into the current directory:
```
```no-highlight
# git clone -b master https://github.com/digitalocean/netbox.git .
Cloning into '.'...
remote: Counting objects: 1994, done.
@@ -67,7 +66,7 @@ Checking connectivity... done.
Install the required Python packages using pip. (If you encounter any compilation errors during this step, ensure that you've installed all of the system dependencies listed above.)
```
```no-highlight
# pip install -r requirements.txt
```
@@ -75,7 +74,7 @@ Install the required Python packages using pip. (If you encounter any compilatio
Move into the NetBox configuration directory and make a copy of `configuration.example.py` named `configuration.py`.
```
```no-highlight
# cd netbox/netbox/
# cp configuration.example.py configuration.py
```
@@ -92,7 +91,7 @@ This is a list of the valid hostnames by which this server can be reached. You m
Example:
```
```python
ALLOWED_HOSTS = ['netbox.example.com', '192.0.2.123']
```
@@ -102,7 +101,7 @@ This parameter holds the database configuration details. You must define the use
Example:
```
```python
DATABASE = {
'NAME': 'netbox', # Database name
'USER': 'netbox', # PostgreSQL username
@@ -125,7 +124,7 @@ You may use the script located at `netbox/generate_secret_key.py` to generate a
Before NetBox can run, we need to install the database schema. This is done by running `./manage.py migrate` from the `netbox` directory (`/opt/netbox/netbox/` in our example):
```
```no-highlight
# cd /opt/netbox/netbox/
# ./manage.py migrate
Operations to perform:
@@ -144,7 +143,7 @@ If this step results in a PostgreSQL authentication error, ensure that the usern
NetBox does not come with any predefined user accounts. You'll need to create a super user to be able to log into NetBox:
```
```no-highlight
# ./manage.py createsuperuser
Username: admin
Email address: admin@example.com
@@ -155,7 +154,7 @@ Superuser created successfully.
# Collect Static Files
```
```no-highlight
# ./manage.py collectstatic
You have requested to collect static files at the destination
@@ -176,7 +175,7 @@ NetBox ships with some initial data to help you get started: RIR definitions, co
!!! note
This step is optional. It's perfectly fine to start using NetBox without using this initial data if you'd rather create everything from scratch.
```
```no-highlight
# ./manage.py loaddata initial_data
Installed 43 object(s) from 4 fixture(s)
```
@@ -185,7 +184,7 @@ Installed 43 object(s) from 4 fixture(s)
At this point, NetBox should be able to run. We can verify this by starting a development instance:
```
```no-highlight
# ./manage.py runserver 0.0.0.0:8000 --insecure
Performing system checks...
@@ -196,7 +195,7 @@ Starting development server at http://0.0.0.0:8000/
Quit the server with CONTROL-C.
```
Now if we navigate to the name or IP of the server (as defined in `ALLOWED_HOSTS`) we should be greeted with the NetBox home page. Note that this built-in web service is for development and testing purposes only. It is not suited for production use.
Now if we navigate to the name or IP of the server (as defined in `ALLOWED_HOSTS`) we should be greeted with the NetBox home page. Note that this built-in web service is for development and testing purposes only. **It is not suited for production use.**
!!! warning
If the test service does not run, or you cannot reach the NetBox home page, something has gone wrong. Do not proceed with the rest of this guide until the installation has been corrected.

View File

@@ -1,30 +1,30 @@
NetBox requires a PostgreSQL database to store data. MySQL is not supported, as NetBox leverage's PostgreSQL's built-in [network address types](https://www.postgresql.org/docs/9.1/static/datatype-net-types.html).
NetBox requires a PostgreSQL database to store data. (Please note that MySQL is not supported, as NetBox leverages PostgreSQL's built-in [network address types](https://www.postgresql.org/docs/9.1/static/datatype-net-types.html).)
# Installation
**Debian/Ubuntu**
```
```no-highlight
# apt-get install -y postgresql libpq-dev python-psycopg2
```
**CentOS/RHEL**
```
```no-highlight
# yum install -y postgresql postgresql-server postgresql-devel python-psycopg2
# postgresql-setup initdb
```
If using CentOS, modify the PostgreSQL configuration to accept password-based authentication by replacing `ident` with `md5` for all host entries within `/var/lib/pgsql/data/pg_hba.conf`. For example:
CentOS users should modify the PostgreSQL configuration to accept password-based authentication by replacing `ident` with `md5` for all host entries within `/var/lib/pgsql/data/pg_hba.conf`. For example:
```
```no-highlight
host all all 127.0.0.1/32 md5
host all all ::1/128 md5
```
Then, start the service:
```
```no-highlight
# systemctl start postgresql
```
@@ -35,7 +35,7 @@ At a minimum, we need to create a database for NetBox and assign it a username a
!!! danger
DO NOT USE THE PASSWORD FROM THE EXAMPLE.
```
```no-highlight
# sudo -u postgres psql
psql (9.3.13)
Type "help" for help.
@@ -51,7 +51,7 @@ postgres=# \q
You can verify that authentication works issuing the following command and providing the configured password:
```
```no-highlight
# psql -U netbox -h localhost -W
```

View File

@@ -8,7 +8,7 @@ Download the [latest stable release](https://github.com/digitalocean/netbox/rele
Download and extract the latest version:
```
```no-highlight
# wget https://github.com/digitalocean/netbox/archive/vX.Y.Z.tar.gz
# tar -xzf vX.Y.Z.tar.gz -C /opt
# cd /opt/
@@ -17,13 +17,13 @@ Download and extract the latest version:
Copy the 'configuration.py' you created when first installing to the new version:
```
```no-highlight
# cp /opt/netbox-X.Y.Z/netbox/netbox/configuration.py /opt/netbox/netbox/netbox/configuration.py
```
If you followed the original installation guide to set up gunicorn, be sure to copy its configuration as well:
```
```no-highlight
# cp /opt/netbox-X.Y.Z/gunicorn_config.py /opt/netbox/gunicorn_config.py
```
@@ -31,7 +31,7 @@ If you followed the original installation guide to set up gunicorn, be sure to c
This guide assumes that NetBox is installed at `/opt/netbox`. Pull down the most recent iteration of the master branch:
```
```no-highlight
# cd /opt/netbox
# git checkout master
# git pull origin master
@@ -42,7 +42,7 @@ This guide assumes that NetBox is installed at `/opt/netbox`. Pull down the most
Once the new code is in place, run the upgrade script (which may need to be run as root depending on how your environment is configured).
```
```no-highlight
# ./upgrade.sh
```
@@ -56,6 +56,6 @@ This script:
Finally, restart the WSGI service to run the new code. If you followed this guide for the initial installation, this is done using `supervisorctl`:
```
```no-highlight
# sudo supervisorctl restart netbox
```

View File

@@ -5,7 +5,7 @@ We'll set up a simple WSGI front end using [gunicorn](http://gunicorn.org/) for
!!! info
Only Debian/Ubuntu instructions are provided here, but the installation process for CentOS/RHEL does not differ much. Please consult the documentation for those distributions for details.
```
```no-highlight
# apt-get install -y gunicorn supervisor
```
@@ -13,13 +13,13 @@ We'll set up a simple WSGI front end using [gunicorn](http://gunicorn.org/) for
The following will serve as a minimal nginx configuration. Be sure to modify your server name and installation path appropriately.
```
```no-highlight
# apt-get install -y nginx
```
Once nginx is installed, save the following configuration to `/etc/nginx/sites-available/netbox`. Be sure to replace `netbox.example.com` with the domain name or IP address of your installation. (This should match the value configured for `ALLOWED_HOSTS` in `configuration.py`.)
```
```nginx
server {
listen 80;
@@ -43,7 +43,7 @@ server {
Then, delete `/etc/nginx/sites-enabled/default` and create a symlink in the `sites-enabled` directory to the configuration file you just created.
```
```no-highlight
# cd /etc/nginx/sites-enabled/
# rm default
# ln -s /etc/nginx/sites-available/netbox
@@ -51,7 +51,7 @@ Then, delete `/etc/nginx/sites-enabled/default` and create a symlink in the `sit
Restart the nginx service to use the new configuration.
```
```no-highlight
# service nginx restart
```
@@ -59,13 +59,13 @@ To enable SSL, consider this guide on [securing nginx with Let's Encrypt](https:
## Option B: Apache
```
```no-highlight
# apt-get install -y apache2
```
Once Apache is installed, proceed with the following configuration (Be sure to modify the `ServerName` appropriately):
```
```apache
<VirtualHost *:80>
ProxyPreserveHost On
@@ -90,7 +90,7 @@ Once Apache is installed, proceed with the following configuration (Be sure to m
Save the contents of the above example in `/etc/apache2/sites-available/netbox.conf`, enable the `proxy` and `proxy_http` modules, and reload Apache:
```
```no-highlight
# a2enmod proxy
# a2enmod proxy_http
# a2ensite netbox
@@ -101,9 +101,9 @@ To enable SSL, consider this guide on [securing Apache with Let's Encrypt](https
# gunicorn Installation
Save the following configuration file in the root netbox installation path (in this example, `/opt/netbox/`) as `gunicorn_config.py`. Be sure to verify the location of the gunicorn executable (e.g. `which gunicorn`) and to update the `pythonpath` variable if needed. If using CentOS/RHEL change the username from `www-data` to `nginx` or `apache`.
Save the following configuration in the root netbox installation path as `gunicorn_config.py` (e.g. `/opt/netbox/gunicorn_config.py` per our example installation). Be sure to verify the location of the gunicorn executable on your server (e.g. `which gunicorn`) and to update the `pythonpath` variable if needed. If using CentOS/RHEL, change the username from `www-data` to `nginx` or `apache`.
```
```no-highlight
command = '/usr/bin/gunicorn'
pythonpath = '/opt/netbox/netbox'
bind = '127.0.0.1:8001'
@@ -113,9 +113,9 @@ user = 'www-data'
# supervisord Installation
Save the following as `/etc/supervisor/conf.d/netbox.conf`. Update the `command` and `directory` paths as needed.
Save the following as `/etc/supervisor/conf.d/netbox.conf`. Update the `command` and `directory` paths as needed. If using CentOS/RHEL, change the username from `www-data` to `nginx` or `apache`.
```
```no-highlight
[program:netbox]
command = gunicorn -c /opt/netbox/gunicorn_config.py netbox.wsgi
directory = /opt/netbox/netbox/
@@ -124,7 +124,7 @@ user = www-data
Then, restart the supervisor service to detect and run the gunicorn service:
```
```no-highlight
# service supervisor restart
```

View File

@@ -21,11 +21,9 @@ class CircuitTypeAdmin(admin.ModelAdmin):
@admin.register(Circuit)
class CircuitAdmin(admin.ModelAdmin):
list_display = ['cid', 'provider', 'type', 'tenant', 'site', 'install_date', 'port_speed_human',
'upstream_speed_human', 'commit_rate_human', 'xconnect_id']
list_display = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate_human']
list_filter = ['provider', 'type', 'tenant']
exclude = ['interface']
def get_queryset(self, request):
qs = super(CircuitAdmin, self).get_queryset(request)
return qs.select_related('provider', 'type', 'tenant', 'site')
return qs.select_related('provider', 'type', 'tenant')

View File

@@ -1,6 +1,6 @@
from rest_framework import serializers
from circuits.models import Provider, CircuitType, Circuit
from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer
from extras.api.serializers import CustomFieldSerializer
from tenancy.api.serializers import TenantNestedSerializer
@@ -45,17 +45,25 @@ class CircuitTypeNestedSerializer(CircuitTypeSerializer):
# Circuits
#
class CircuitSerializer(CustomFieldSerializer, serializers.ModelSerializer):
provider = ProviderNestedSerializer()
type = CircuitTypeNestedSerializer()
tenant = TenantNestedSerializer()
class CircuitTerminationSerializer(serializers.ModelSerializer):
site = SiteNestedSerializer()
interface = InterfaceNestedSerializer()
class Meta:
model = CircuitTermination
fields = ['id', 'term_side', 'site', 'interface', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info']
class CircuitSerializer(CustomFieldSerializer, serializers.ModelSerializer):
provider = ProviderNestedSerializer()
type = CircuitTypeNestedSerializer()
tenant = TenantNestedSerializer()
terminations = CircuitTerminationSerializer(many=True)
class Meta:
model = Circuit
fields = ['id', 'cid', 'provider', 'type', 'tenant', 'site', 'interface', 'install_date', 'port_speed',
'upstream_speed', 'commit_rate', 'xconnect_id', 'comments', 'custom_fields']
fields = ['id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
'terminations', 'custom_fields']
class CircuitNestedSerializer(CircuitSerializer):

View File

@@ -43,7 +43,7 @@ class CircuitListView(CustomFieldModelAPIView, generics.ListAPIView):
"""
List circuits (filterable)
"""
queryset = Circuit.objects.select_related('type', 'tenant', 'provider', 'site', 'interface__device')\
queryset = Circuit.objects.select_related('type', 'tenant', 'provider')\
.prefetch_related('custom_field_values__field')
serializer_class = serializers.CircuitSerializer
filter_class = CircuitFilter
@@ -53,6 +53,6 @@ class CircuitDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
"""
Retrieve a single circuit
"""
queryset = Circuit.objects.select_related('type', 'tenant', 'provider', 'site', 'interface__device')\
queryset = Circuit.objects.select_related('type', 'tenant', 'provider')\
.prefetch_related('custom_field_values__field')
serializer_class = serializers.CircuitSerializer

View File

@@ -16,12 +16,12 @@ class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet):
label='Search',
)
site_id = django_filters.ModelMultipleChoiceFilter(
name='circuits__site',
name='circuits__terminations__site',
queryset=Site.objects.all(),
label='Site',
)
site = django_filters.ModelMultipleChoiceFilter(
name='circuits__site',
name='circuits__terminations__site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site (slug)',
@@ -29,7 +29,7 @@ class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet):
class Meta:
model = Provider
fields = ['q', 'name', 'account', 'asn']
fields = ['name', 'account', 'asn']
def search(self, queryset, value):
return queryset.filter(
@@ -50,7 +50,7 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
label='Provider (ID)',
)
provider = django_filters.ModelMultipleChoiceFilter(
name='provider',
name='provider__slug',
queryset=Provider.objects.all(),
to_field_name='slug',
label='Provider (slug)',
@@ -61,7 +61,7 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
label='Circuit type (ID)',
)
type = django_filters.ModelMultipleChoiceFilter(
name='type',
name='type__slug',
queryset=CircuitType.objects.all(),
to_field_name='slug',
label='Circuit type (slug)',
@@ -78,12 +78,12 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
label='Tenant (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter(
name='site',
name='terminations__site',
queryset=Site.objects.all(),
label='Site (ID)',
)
site = django_filters.ModelMultipleChoiceFilter(
name='site',
name='terminations__site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site (slug)',
@@ -91,12 +91,13 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
class Meta:
model = Circuit
fields = ['q', 'provider_id', 'provider', 'type_id', 'type', 'site_id', 'site', 'interface', 'install_date']
fields = ['install_date']
def search(self, queryset, value):
return queryset.filter(
Q(cid__icontains=value) |
Q(xconnect_id__icontains=value) |
Q(pp_info__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

@@ -9,7 +9,7 @@ from utilities.forms import (
SlugField,
)
from .models import Circuit, CircuitType, Provider
from .models import Circuit, CircuitTermination, CircuitType, Provider
#
@@ -43,7 +43,7 @@ class ProviderFromCSVForm(forms.ModelForm):
fields = ['name', 'slug', 'asn', 'account', 'portal_url']
class ProviderImportForm(BulkImportForm, BootstrapMixin):
class ProviderImportForm(BootstrapMixin, BulkImportForm):
csv = CSVDataField(csv_form=ProviderFromCSVForm)
@@ -54,7 +54,7 @@ class ProviderBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
portal_url = forms.URLField(required=False, label='Portal')
noc_contact = forms.CharField(required=False, widget=SmallTextarea, label='NOC contact')
admin_contact = forms.CharField(required=False, widget=SmallTextarea, label='Admin contact')
comments = CommentField()
comments = CommentField(widget=SmallTextarea)
class Meta:
nullable_fields = ['asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments']
@@ -69,7 +69,7 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm):
# Circuit types
#
class CircuitTypeForm(forms.ModelForm, BootstrapMixin):
class CircuitTypeForm(BootstrapMixin, forms.ModelForm):
slug = SlugField()
class Meta:
@@ -82,6 +82,65 @@ class CircuitTypeForm(forms.ModelForm, BootstrapMixin):
#
class CircuitForm(BootstrapMixin, CustomFieldForm):
comments = CommentField()
class Meta:
model = Circuit
fields = ['cid', 'type', 'provider', 'tenant', 'install_date', 'commit_rate', 'description', 'comments']
help_texts = {
'cid': "Unique circuit ID",
'install_date': "Format: YYYY-MM-DD",
'commit_rate': "Committed rate",
}
class CircuitFromCSVForm(forms.ModelForm):
provider = forms.ModelChoiceField(Provider.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Provider not found.'})
type = forms.ModelChoiceField(CircuitType.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Invalid circuit type.'})
tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
error_messages={'invalid_choice': 'Tenant not found.'})
class Meta:
model = Circuit
fields = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description']
class CircuitImportForm(BootstrapMixin, BulkImportForm):
csv = CSVDataField(csv_form=CircuitFromCSVForm)
class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Circuit.objects.all(), widget=forms.MultipleHiddenInput)
type = forms.ModelChoiceField(queryset=CircuitType.objects.all(), required=False)
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', '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')
#
# Circuit terminations
#
class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.Select(attrs={'filter-for': 'rack'}))
rack = forms.ModelChoiceField(queryset=Rack.objects.all(), required=False, label='Rack',
widget=APISelect(api_url='/api/dcim/racks/?site_id={{site}}',
@@ -95,28 +154,25 @@ class CircuitForm(BootstrapMixin, CustomFieldForm):
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'))
comments = CommentField()
class Meta:
model = Circuit
fields = [
'cid', 'type', 'provider', 'tenant', 'site', 'rack', 'device', 'livesearch', 'interface', 'install_date',
'port_speed', 'upstream_speed', 'commit_rate', 'xconnect_id', 'pp_info', 'comments'
]
model = CircuitTermination
fields = ['term_side', 'site', 'rack', 'device', 'livesearch', 'interface', 'port_speed', 'upstream_speed',
'xconnect_id', 'pp_info']
help_texts = {
'cid': "Unique circuit ID",
'install_date': "Format: YYYY-MM-DD",
'port_speed': "Physical circuit speed",
'commit_rate': "Commited rate",
'xconnect_id': "ID of the local cross-connect",
'pp_info': "Patch panel ID and port number(s)"
}
widgets = {
'term_side': forms.HiddenInput(),
}
def __init__(self, *args, **kwargs):
super(CircuitForm, self).__init__(*args, **kwargs)
super(CircuitTerminationForm, self).__init__(*args, **kwargs)
# If this circuit has been assigned to an interface, initialize rack and device
# If an interface has been assigned, initialize rack and device
if self.instance.interface:
self.initial['rack'] = self.instance.interface.device.rack
self.initial['device'] = self.instance.interface.device
@@ -140,11 +196,13 @@ class CircuitForm(BootstrapMixin, CustomFieldForm):
# 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', 'connected_as_a', 'connected_as_b')
.exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit_termination', 'connected_as_a',
'connected_as_b')
self.fields['interface'].widget.attrs['initial'] = self.data.get('interface')
elif self.initial.get('device'):
interfaces = Interface.objects.filter(device=self.initial['device'])\
.exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit', 'connected_as_a', 'connected_as_b')
.exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit_termination', 'connected_as_a',
'connected_as_b')
self.fields['interface'].widget.attrs['initial'] = self.initial.get('interface')
else:
interfaces = []
@@ -154,47 +212,3 @@ class CircuitForm(BootstrapMixin, CustomFieldForm):
'disabled': iface.is_connected and iface.id != self.fields['interface'].widget.attrs.get('initial'),
}) for iface in interfaces
]
class CircuitFromCSVForm(forms.ModelForm):
provider = forms.ModelChoiceField(Provider.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Provider not found.'})
type = forms.ModelChoiceField(CircuitType.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Invalid circuit type.'})
tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
error_messages={'invalid_choice': 'Tenant not found.'})
site = forms.ModelChoiceField(Site.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Site not found.'})
class Meta:
model = Circuit
fields = ['cid', 'provider', 'type', 'tenant', 'site', 'install_date', 'port_speed', 'upstream_speed',
'commit_rate', 'xconnect_id', 'pp_info']
class CircuitImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=CircuitFromCSVForm)
class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Circuit.objects.all(), widget=forms.MultipleHiddenInput)
type = forms.ModelChoiceField(queryset=CircuitType.objects.all(), required=False)
provider = forms.ModelChoiceField(queryset=Provider.objects.all(), required=False)
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
port_speed = forms.IntegerField(required=False, label='Port speed (Kbps)')
commit_rate = forms.IntegerField(required=False, label='Commit rate (Kbps)')
comments = CommentField()
class Meta:
nullable_fields = ['tenant', 'port_speed', 'commit_rate', '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('circuits')), to_field_name='slug')

View File

@@ -0,0 +1,99 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10 on 2016-12-13 16:30
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
def circuits_to_terms(apps, schema_editor):
Circuit = apps.get_model('circuits', 'Circuit')
CircuitTermination = apps.get_model('circuits', 'CircuitTermination')
for c in Circuit.objects.all():
CircuitTermination(
circuit=c,
term_side=b'A',
site=c.site,
interface=c.interface,
port_speed=c.port_speed,
upstream_speed=c.upstream_speed,
xconnect_id=c.xconnect_id,
pp_info=c.pp_info,
).save()
def terms_to_circuits(apps, schema_editor):
CircuitTermination = apps.get_model('circuits', 'CircuitTermination')
for ct in CircuitTermination.objects.filter(term_side='A'):
c = ct.circuit
c.site = ct.site
c.interface = ct.interface
c.port_speed = ct.port_speed
c.upstream_speed = ct.upstream_speed
c.xconnect_id = ct.xconnect_id
c.pp_info = ct.pp_info
c.save()
class Migration(migrations.Migration):
dependencies = [
('dcim', '0022_color_names_to_rgb'),
('circuits', '0005_circuit_add_upstream_speed'),
]
operations = [
migrations.CreateModel(
name='CircuitTermination',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('term_side', models.CharField(choices=[(b'A', b'A'), (b'Z', b'Z')], max_length=1,
verbose_name='Termination')),
('port_speed', models.PositiveIntegerField(verbose_name=b'Port speed (Kbps)')),
('upstream_speed',
models.PositiveIntegerField(blank=True, help_text=b'Upstream speed, if different from port speed',
null=True, verbose_name=b'Upstream speed (Kbps)')),
('xconnect_id', models.CharField(blank=True, max_length=50, verbose_name=b'Cross-connect ID')),
('pp_info', models.CharField(blank=True, max_length=100, verbose_name=b'Patch panel/port(s)')),
('circuit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='terminations',
to='circuits.Circuit')),
('interface', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
related_name='circuit_termination', to='dcim.Interface')),
('site',
models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='circuit_terminations',
to='dcim.Site')),
],
options={
'ordering': ['circuit', 'term_side'],
},
),
migrations.AlterUniqueTogether(
name='circuittermination',
unique_together=set([('circuit', 'term_side')]),
),
migrations.RunPython(circuits_to_terms, terms_to_circuits),
migrations.RemoveField(
model_name='circuit',
name='interface',
),
migrations.RemoveField(
model_name='circuit',
name='port_speed',
),
migrations.RemoveField(
model_name='circuit',
name='pp_info',
),
migrations.RemoveField(
model_name='circuit',
name='site',
),
migrations.RemoveField(
model_name='circuit',
name='upstream_speed',
),
migrations.RemoveField(
model_name='circuit',
name='xconnect_id',
),
]

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

@@ -3,12 +3,36 @@ from django.core.urlresolvers import reverse
from django.db import models
from dcim.fields import ASNField
from dcim.models import Site, Interface
from extras.models import CustomFieldModel, CustomFieldValue
from tenancy.models import Tenant
from utilities.utils import csv_format
from utilities.models import CreatedUpdatedModel
TERM_SIDE_A = 'A'
TERM_SIDE_Z = 'Z'
TERM_SIDE_CHOICES = (
(TERM_SIDE_A, 'A'),
(TERM_SIDE_Z, 'Z'),
)
def humanize_speed(speed):
"""
Humanize speeds given in Kbps (e.g. 10000000 becomes '10 Gbps')
"""
if speed >= 1000000000 and speed % 1000000000 == 0:
return '{} Tbps'.format(speed / 1000000000)
elif speed >= 1000000 and speed % 1000000 == 0:
return '{} Gbps'.format(speed / 1000000)
elif speed >= 1000 and speed % 1000 == 0:
return '{} Mbps'.format(speed / 1000)
elif speed >= 1000:
return '{} Mbps'.format(float(speed) / 1000)
else:
return '{} Kbps'.format(speed)
class Provider(CreatedUpdatedModel, CustomFieldModel):
"""
Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model
@@ -34,10 +58,10 @@ class Provider(CreatedUpdatedModel, CustomFieldModel):
return reverse('circuits:provider', args=[self.slug])
def to_csv(self):
return ','.join([
return csv_format([
self.name,
self.slug,
str(self.asn) if self.asn else '',
self.asn,
self.account,
self.portal_url,
])
@@ -45,7 +69,7 @@ class Provider(CreatedUpdatedModel, CustomFieldModel):
class CircuitType(models.Model):
"""
Circuits can be orgnanized by their functional role. For example, a user might wish to define CircuitTypes named
Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named
"Long Haul," "Metro," or "Out-of-Band".
"""
name = models.CharField(max_length=50, unique=True)
@@ -71,15 +95,9 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
provider = models.ForeignKey('Provider', related_name='circuits', on_delete=models.PROTECT)
type = models.ForeignKey('CircuitType', related_name='circuits', on_delete=models.PROTECT)
tenant = models.ForeignKey(Tenant, related_name='circuits', blank=True, null=True, on_delete=models.PROTECT)
site = models.ForeignKey(Site, related_name='circuits', on_delete=models.PROTECT)
interface = models.OneToOneField(Interface, related_name='circuit', blank=True, null=True)
install_date = models.DateField(blank=True, null=True, verbose_name='Date installed')
port_speed = models.PositiveIntegerField(verbose_name='Port speed (Kbps)')
upstream_speed = models.PositiveIntegerField(blank=True, null=True, verbose_name='Upstream speed (Kbps)',
help_text='Upstream speed, if different from port speed')
commit_rate = models.PositiveIntegerField(blank=True, null=True, verbose_name='Commit rate (Kbps)')
xconnect_id = models.CharField(max_length=50, blank=True, verbose_name='Cross-connect ID')
pp_info = models.CharField(max_length=100, blank=True, verbose_name='Patch panel/port(s)')
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')
@@ -94,47 +112,64 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
return reverse('circuits:circuit', args=[self.pk])
def to_csv(self):
return ','.join([
return csv_format([
self.cid,
self.provider.name,
self.type.name,
self.tenant.name if self.tenant else '',
self.site.name,
self.install_date.isoformat() if self.install_date else '',
str(self.port_speed),
str(self.upstream_speed),
str(self.commit_rate) if self.commit_rate else '',
self.xconnect_id,
self.pp_info,
self.tenant.name if self.tenant else None,
self.install_date.isoformat() if self.install_date else None,
self.commit_rate,
self.description,
])
def _humanize_speed(self, speed):
"""
Humanize speeds given in Kbps (e.g. 10000000 becomes '10 Gbps')
"""
if speed >= 1000000000 and speed % 1000000000 == 0:
return '{} Tbps'.format(speed / 1000000000)
elif speed >= 1000000 and speed % 1000000 == 0:
return '{} Gbps'.format(speed / 1000000)
elif speed >= 1000 and speed % 1000 == 0:
return '{} Mbps'.format(speed / 1000)
elif speed >= 1000:
return '{} Mbps'.format(float(speed) / 1000)
else:
return '{} Kbps'.format(speed)
def _get_termination(self, side):
for ct in self.terminations.all():
if ct.term_side == side:
return ct
return None
@property
def termination_a(self):
return self._get_termination('A')
@property
def termination_z(self):
return self._get_termination('Z')
def commit_rate_human(self):
return '' if not self.commit_rate else humanize_speed(self.commit_rate)
commit_rate_human.admin_order_field = 'commit_rate'
class CircuitTermination(models.Model):
circuit = models.ForeignKey('Circuit', related_name='terminations', on_delete=models.CASCADE)
term_side = models.CharField(max_length=1, choices=TERM_SIDE_CHOICES, verbose_name='Termination')
site = models.ForeignKey('dcim.Site', related_name='circuit_terminations', on_delete=models.PROTECT)
interface = models.OneToOneField('dcim.Interface', related_name='circuit_termination', blank=True, null=True)
port_speed = models.PositiveIntegerField(verbose_name='Port speed (Kbps)')
upstream_speed = models.PositiveIntegerField(blank=True, null=True, verbose_name='Upstream speed (Kbps)',
help_text='Upstream speed, if different from port speed')
xconnect_id = models.CharField(max_length=50, blank=True, verbose_name='Cross-connect ID')
pp_info = models.CharField(max_length=100, blank=True, verbose_name='Patch panel/port(s)')
class Meta:
ordering = ['circuit', 'term_side']
unique_together = ['circuit', 'term_side']
def __unicode__(self):
return u'{} (Side {})'.format(self.circuit, self.get_term_side_display())
def get_peer_termination(self):
peer_side = 'Z' if self.term_side == 'A' else 'A'
try:
return CircuitTermination.objects.select_related('site').get(circuit=self.circuit, term_side=peer_side)
except CircuitTermination.DoesNotExist:
return None
def port_speed_human(self):
return self._humanize_speed(self.port_speed)
return humanize_speed(self.port_speed)
port_speed_human.admin_order_field = 'port_speed'
def upstream_speed_human(self):
if not self.upstream_speed:
return ''
return self._humanize_speed(self.upstream_speed)
return '' if not self.upstream_speed else humanize_speed(self.upstream_speed)
upstream_speed_human.admin_order_field = 'upstream_speed'
def commit_rate_human(self):
if not self.commit_rate:
return ''
return self._humanize_speed(self.commit_rate)
commit_rate_human.admin_order_field = 'commit_rate'

View File

@@ -56,12 +56,12 @@ class CircuitTable(BaseTable):
type = tables.Column(verbose_name='Type')
provider = tables.LinkColumn('circuits:provider', args=[Accessor('provider.slug')], verbose_name='Provider')
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
port_speed = tables.Column(accessor=Accessor('port_speed_human'), order_by=Accessor('port_speed'),
verbose_name='Port Speed')
commit_rate = tables.Column(accessor=Accessor('commit_rate_human'), order_by=Accessor('commit_rate'),
verbose_name='Commit Rate')
a_side = tables.LinkColumn('dcim:site', accessor=Accessor('termination_a.site'), orderable=False,
args=[Accessor('termination_a.site.slug')])
z_side = tables.LinkColumn('dcim:site', accessor=Accessor('termination_z.site'), orderable=False,
args=[Accessor('termination_z.site.slug')])
description = tables.Column(verbose_name='Description')
class Meta(BaseTable.Meta):
model = Circuit
fields = ('pk', 'cid', 'type', 'provider', 'tenant', 'site', 'port_speed', 'commit_rate')
fields = ('pk', 'cid', 'type', 'provider', 'tenant', 'a_side', 'z_side', 'description')

View File

@@ -30,5 +30,11 @@ urlpatterns = [
url(r'^circuits/(?P<pk>\d+)/$', views.circuit, name='circuit'),
url(r'^circuits/(?P<pk>\d+)/edit/$', views.CircuitEditView.as_view(), name='circuit_edit'),
url(r'^circuits/(?P<pk>\d+)/delete/$', views.CircuitDeleteView.as_view(), name='circuit_delete'),
url(r'^circuits/(?P<pk>\d+)/terminations/swap/$', views.circuit_terminations_swap, name='circuit_terminations_swap'),
# Circuit terminations
url(r'^circuits/(?P<circuit>\d+)/terminations/add/$', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'),
url(r'^circuit-terminations/(?P<pk>\d+)/edit/$', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'),
url(r'^circuit-terminations/(?P<pk>\d+)/delete/$', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'),
]

View File

@@ -1,14 +1,19 @@
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, render
from django.shortcuts import get_object_or_404, redirect, render
from extras.models import Graph, GRAPH_TYPE_PROVIDER
from utilities.forms import ConfirmationForm
from utilities.views import (
BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
)
from . import filters, forms, tables
from .models import Circuit, CircuitType, Provider
from .models import Circuit, CircuitTermination, CircuitType, Provider, TERM_SIDE_A, TERM_SIDE_Z
#
@@ -27,7 +32,7 @@ class ProviderListView(ObjectListView):
def provider(request, slug):
provider = get_object_or_404(Provider, slug=slug)
circuits = Circuit.objects.filter(provider=provider).select_related('site', 'interface__device')
circuits = Circuit.objects.filter(provider=provider)
show_graphs = Graph.objects.filter(type=GRAPH_TYPE_PROVIDER).exists()
return render(request, 'circuits/provider.html', {
@@ -42,13 +47,13 @@ class ProviderEditView(PermissionRequiredMixin, ObjectEditView):
model = Provider
form_class = forms.ProviderForm
template_name = 'circuits/provider_edit.html'
cancel_url = 'circuits:provider_list'
obj_list_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):
@@ -88,8 +93,9 @@ class CircuitTypeEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'circuits.change_circuittype'
model = CircuitType
form_class = forms.CircuitTypeForm
success_url = 'circuits:circuittype_list'
cancel_url = 'circuits:circuittype_list'
def get_return_url(self, obj):
return reverse('circuits:circuittype_list')
class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -103,7 +109,7 @@ class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
#
class CircuitListView(ObjectListView):
queryset = Circuit.objects.select_related('provider', 'type', 'tenant', 'site')
queryset = Circuit.objects.select_related('provider', 'type', 'tenant').prefetch_related('terminations__site')
filter = filters.CircuitFilter
filter_form = forms.CircuitFilterForm
table = tables.CircuitTable
@@ -114,9 +120,13 @@ class CircuitListView(ObjectListView):
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()
return render(request, 'circuits/circuit.html', {
'circuit': circuit,
'termination_a': termination_a,
'termination_z': termination_z,
})
@@ -124,15 +134,15 @@ class CircuitEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'circuits.change_circuit'
model = Circuit
form_class = forms.CircuitForm
fields_initial = ['site']
fields_initial = ['provider']
template_name = 'circuits/circuit_edit.html'
cancel_url = 'circuits:circuit_list'
obj_list_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):
@@ -155,3 +165,73 @@ class CircuitBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'circuits.delete_circuit'
cls = Circuit
default_redirect_url = 'circuits:circuit_list'
@permission_required('circuits.change_circuittermination')
def circuit_terminations_swap(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()
if not termination_a and not termination_z:
messages.error(request, "No terminations have been defined for circuit {}.".format(circuit))
return redirect('circuits:circuit', pk=circuit.pk)
if request.method == 'POST':
form = ConfirmationForm(request.POST)
if form.is_valid():
if termination_a and termination_z:
# Use a placeholder to avoid an IntegrityError on the (circuit, term_side) unique constraint
with transaction.atomic():
termination_a.term_side = '_'
termination_a.save()
termination_z.term_side = 'A'
termination_z.save()
termination_a.term_side = 'Z'
termination_a.save()
elif termination_a:
termination_a.term_side = 'Z'
termination_a.save()
else:
termination_z.term_side = 'A'
termination_z.save()
messages.success(request, "Swapped terminations for circuit {}.".format(circuit))
return redirect('circuits:circuit', pk=circuit.pk)
else:
form = ConfirmationForm()
return render(request, 'circuits/circuit_terminations_swap.html', {
'circuit': circuit,
'termination_a': termination_a,
'termination_z': termination_z,
'form': form,
'panel_class': 'default',
'button_class': 'primary',
'cancel_url': circuit.get_absolute_url(),
})
#
# Circuit terminations
#
class CircuitTerminationEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'circuits.change_circuittermination'
model = CircuitTermination
form_class = forms.CircuitTerminationForm
fields_initial = ['term_side']
template_name = 'circuits/circuittermination_edit.html'
def alter_obj(self, obj, args, kwargs):
if 'circuit' in kwargs:
obj.circuit = get_object_or_404(Circuit, pk=kwargs['circuit'])
return obj
def get_return_url(self, obj):
return obj.circuit.get_absolute_url()
class CircuitTerminationDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'circuits.delete_circuittermination'
model = CircuitTermination

View File

@@ -183,10 +183,14 @@ class DeviceAdmin(admin.ModelAdmin):
DeviceBayAdmin,
ModuleAdmin,
]
list_display = ['display_name', 'device_type', 'device_role', 'primary_ip', 'rack', 'position', 'asset_tag',
list_display = ['display_name', 'device_type_full_name', 'device_role', 'primary_ip', 'rack', 'position', 'asset_tag',
'serial']
list_filter = ['device_role']
def get_queryset(self, request):
qs = super(DeviceAdmin, self).get_queryset(request)
return qs.select_related('device_type__manufacturer', 'device_role', 'primary_ip4', 'primary_ip6', 'rack')
def device_type_full_name(self, obj):
return obj.device_type.full_name
device_type_full_name.short_description = 'Device type'

View File

@@ -20,8 +20,9 @@ class SiteSerializer(CustomFieldSerializer, serializers.ModelSerializer):
class Meta:
model = Site
fields = ['id', 'name', 'slug', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', 'comments',
'custom_fields', 'count_prefixes', 'count_vlans', 'count_racks', 'count_devices', 'count_circuits']
fields = ['id', 'name', 'slug', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address',
'contact_name', 'contact_phone', 'contact_email', 'comments', 'custom_fields', 'count_prefixes',
'count_vlans', 'count_racks', 'count_devices', 'count_circuits']
class SiteNestedSerializer(SiteSerializer):
@@ -130,14 +131,15 @@ class ManufacturerNestedSerializer(ManufacturerSerializer):
# Device types
#
class DeviceTypeSerializer(serializers.ModelSerializer):
class DeviceTypeSerializer(CustomFieldSerializer, serializers.ModelSerializer):
manufacturer = ManufacturerNestedSerializer()
subdevice_role = serializers.SerializerMethodField()
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']
'interface_ordering', 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role',
'comments', 'custom_fields']
def get_subdevice_role(self, obj):
return {
@@ -197,8 +199,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', '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']
#
@@ -381,7 +384,7 @@ class InterfaceNestedSerializer(InterfaceSerializer):
class InterfaceDetailSerializer(InterfaceSerializer):
connected_interface = InterfaceSerializer(source='get_connected_interface')
connected_interface = InterfaceSerializer()
class Meta(InterfaceSerializer.Meta):
fields = ['id', 'device', 'name', 'form_factor', 'mac_address', 'mgmt_only', 'description', 'is_connected',

View File

@@ -118,7 +118,13 @@ class RackUnitListView(APIView):
rack = get_object_or_404(Rack, pk=pk)
face = request.GET.get('face', 0)
elevation = rack.get_rack_units(face)
exclude_pk = request.GET.get('exclude', None)
if exclude_pk is not None:
try:
exclude_pk = int(exclude_pk)
except ValueError:
exclude_pk = None
elevation = rack.get_rack_units(face, exclude_pk)
# Serialize Devices within the rack elevation
for u in elevation:
@@ -152,20 +158,20 @@ class ManufacturerDetailView(generics.RetrieveAPIView):
# Device Types
#
class DeviceTypeListView(generics.ListAPIView):
class DeviceTypeListView(CustomFieldModelAPIView, generics.ListAPIView):
"""
List device types (filterable)
"""
queryset = DeviceType.objects.select_related('manufacturer')
queryset = DeviceType.objects.select_related('manufacturer').prefetch_related('custom_field_values__field')
serializer_class = serializers.DeviceTypeSerializer
filter_class = filters.DeviceTypeFilter
class DeviceTypeDetailView(generics.RetrieveAPIView):
class DeviceTypeDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
"""
Retrieve a single device type
"""
queryset = DeviceType.objects.select_related('manufacturer')
queryset = DeviceType.objects.select_related('manufacturer').prefetch_related('custom_field_values__field')
serializer_class = serializers.DeviceTypeDetailSerializer
@@ -451,7 +457,7 @@ class RelatedConnectionsView(APIView):
peer_iface = Interface.objects.get(device__name=peer_device, name=peer_interface)
except Interface.DoesNotExist:
raise Http404()
local_iface = peer_iface.get_connected_interface()
local_iface = peer_iface.connected_interface
if local_iface:
device = local_iface.device
else:
@@ -484,7 +490,7 @@ class RelatedConnectionsView(APIView):
# Interface connections
interfaces = Interface.objects.filter(device=device).select_related('connected_as_a', 'connected_as_b',
'circuit')
'circuit_termination')
for iface in interfaces:
data = serializers.InterfaceDetailSerializer(instance=iface).data
del(data['device'])

View File

@@ -1,4 +1,5 @@
import django_filters
from netaddr.core import AddrFormatError
from django.db.models import Q
@@ -49,7 +50,7 @@ class RackGroupFilter(django_filters.FilterSet):
label='Site (ID)',
)
site = django_filters.ModelMultipleChoiceFilter(
name='site',
name='site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site (slug)',
@@ -57,7 +58,6 @@ class RackGroupFilter(django_filters.FilterSet):
class Meta:
model = RackGroup
fields = ['site_id', 'site']
class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
@@ -71,7 +71,7 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
label='Site (ID)',
)
site = django_filters.ModelMultipleChoiceFilter(
name='site',
name='site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site (slug)',
@@ -112,7 +112,7 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
class Meta:
model = Rack
fields = ['q', 'site_id', 'site', 'u_height']
fields = ['u_height']
def search(self, queryset, value):
return queryset.filter(
@@ -122,14 +122,18 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
)
class DeviceTypeFilter(django_filters.FilterSet):
class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
label='Search',
)
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
name='manufacturer',
queryset=Manufacturer.objects.all(),
label='Manufacturer (ID)',
)
manufacturer = django_filters.ModelMultipleChoiceFilter(
name='manufacturer',
name='manufacturer__slug',
queryset=Manufacturer.objects.all(),
to_field_name='slug',
label='Manufacturer (slug)',
@@ -137,8 +141,16 @@ class DeviceTypeFilter(django_filters.FilterSet):
class Meta:
model = DeviceType
fields = ['manufacturer_id', 'manufacturer', 'model', 'part_number', 'u_height', 'is_console_server', 'is_pdu',
'is_network_device']
fields = ['model', 'part_number', 'u_height', 'is_console_server', 'is_pdu', 'is_network_device',
'subdevice_role']
def search(self, queryset, value):
return queryset.filter(
Q(manufacturer__name__icontains=value) |
Q(model__icontains=value) |
Q(part_number__icontains=value) |
Q(comments__icontains=value)
)
class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
@@ -146,13 +158,17 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
action='search',
label='Search',
)
mac_address = django_filters.MethodFilter(
action='_mac_address',
label='MAC address',
)
site_id = django_filters.ModelMultipleChoiceFilter(
name='rack__site',
queryset=Site.objects.all(),
label='Site (ID)',
)
site = django_filters.ModelMultipleChoiceFilter(
name='rack__site',
name='rack__site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site name (slug)',
@@ -173,7 +189,7 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
label='Role (ID)',
)
role = django_filters.ModelMultipleChoiceFilter(
name='device_role',
name='device_role__slug',
queryset=DeviceRole.objects.all(),
to_field_name='slug',
label='Role (slug)',
@@ -200,13 +216,13 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
label='Manufacturer (ID)',
)
manufacturer = django_filters.ModelMultipleChoiceFilter(
name='device_type__manufacturer',
name='device_type__manufacturer__slug',
queryset=Manufacturer.objects.all(),
to_field_name='slug',
label='Manufacturer (slug)',
)
model = django_filters.ModelMultipleChoiceFilter(
name='device_type',
name='device_type__slug',
queryset=DeviceType.objects.all(),
to_field_name='slug',
label='Device model (slug)',
@@ -241,9 +257,7 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
class Meta:
model = Device
fields = ['q', 'name', 'serial', 'asset_tag', 'site_id', 'site', 'rack_id', 'role_id', 'role', 'device_type_id',
'manufacturer_id', 'manufacturer', 'model', 'platform_id', 'platform', 'status', 'is_console_server',
'is_pdu', 'is_network_device']
fields = ['name', 'serial', 'asset_tag']
def search(self, queryset, value):
return queryset.filter(
@@ -254,6 +268,15 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
Q(comments__icontains=value)
).distinct()
def _mac_address(self, queryset, value):
value = value.strip()
if not value:
return queryset
try:
return queryset.filter(interfaces__mac_address=value).distinct()
except AddrFormatError:
return queryset.none()
class ConsolePortFilter(django_filters.FilterSet):
device_id = django_filters.ModelMultipleChoiceFilter(
@@ -270,7 +293,7 @@ class ConsolePortFilter(django_filters.FilterSet):
class Meta:
model = ConsolePort
fields = ['device_id', 'device', 'name']
fields = ['name']
class ConsoleServerPortFilter(django_filters.FilterSet):
@@ -288,7 +311,7 @@ class ConsoleServerPortFilter(django_filters.FilterSet):
class Meta:
model = ConsoleServerPort
fields = ['device_id', 'device', 'name']
fields = ['name']
class PowerPortFilter(django_filters.FilterSet):
@@ -306,7 +329,7 @@ class PowerPortFilter(django_filters.FilterSet):
class Meta:
model = PowerPort
fields = ['device_id', 'device', 'name']
fields = ['name']
class PowerOutletFilter(django_filters.FilterSet):
@@ -324,7 +347,7 @@ class PowerOutletFilter(django_filters.FilterSet):
class Meta:
model = PowerOutlet
fields = ['device_id', 'device', 'name']
fields = ['name']
class InterfaceFilter(django_filters.FilterSet):
@@ -342,7 +365,7 @@ class InterfaceFilter(django_filters.FilterSet):
class Meta:
model = Interface
fields = ['device_id', 'device', 'name']
fields = ['name']
class ConsoleConnectionFilter(django_filters.FilterSet):

View File

@@ -5,7 +5,7 @@
"fields": {
"name": "Console Server",
"slug": "console-server",
"color": "teal"
"color": "009688"
}
},
{
@@ -14,7 +14,7 @@
"fields": {
"name": "Core Switch",
"slug": "core-switch",
"color": "blue"
"color": "2196f3"
}
},
{
@@ -23,7 +23,7 @@
"fields": {
"name": "Distribution Switch",
"slug": "distribution-switch",
"color": "blue"
"color": "2196f3"
}
},
{
@@ -32,7 +32,7 @@
"fields": {
"name": "Access Switch",
"slug": "access-switch",
"color": "blue"
"color": "2196f3"
}
},
{
@@ -41,7 +41,7 @@
"fields": {
"name": "Management Switch",
"slug": "management-switch",
"color": "orange"
"color": "ff9800"
}
},
{
@@ -50,7 +50,7 @@
"fields": {
"name": "Firewall",
"slug": "firewall",
"color": "red"
"color": "f44336"
}
},
{
@@ -59,7 +59,7 @@
"fields": {
"name": "Router",
"slug": "router",
"color": "purple"
"color": "9c27b0"
}
},
{
@@ -68,7 +68,7 @@
"fields": {
"name": "Server",
"slug": "server",
"color": "medium_gray"
"color": "9e9e9e"
}
},
{
@@ -77,7 +77,7 @@
"fields": {
"name": "PDU",
"slug": "pdu",
"color": "dark_gray"
"color": "607d8b"
}
},
{

View File

@@ -13,12 +13,13 @@ from utilities.forms import (
SlugField,
)
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_VIRTUAL, IFACE_ORDERING_CHOICES, InterfaceConnection, InterfaceTemplate,
Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_TYPE_CHOICES,
RACK_WIDTH_CHOICES, Rack, RackGroup, RackRole, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD
)
@@ -61,7 +62,8 @@ class SiteForm(BootstrapMixin, CustomFieldForm):
class Meta:
model = Site
fields = ['name', 'slug', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', 'comments']
fields = ['name', 'slug', '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}),
@@ -81,19 +83,20 @@ class SiteFromCSVForm(forms.ModelForm):
class Meta:
model = Site
fields = ['name', 'slug', 'tenant', 'facility', 'asn']
fields = ['name', 'slug', 'tenant', 'facility', 'asn', 'contact_name', 'contact_phone', 'contact_email']
class SiteImportForm(BulkImportForm, BootstrapMixin):
class SiteImportForm(BootstrapMixin, BulkImportForm):
csv = CSVDataField(csv_form=SiteFromCSVForm)
class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Site.objects.all(), widget=forms.MultipleHiddenInput)
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']
nullable_fields = ['tenant', 'asn']
class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm):
@@ -106,7 +109,7 @@ class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm):
# Rack groups
#
class RackGroupForm(forms.ModelForm, BootstrapMixin):
class RackGroupForm(BootstrapMixin, forms.ModelForm):
slug = SlugField()
class Meta:
@@ -114,7 +117,7 @@ class RackGroupForm(forms.ModelForm, BootstrapMixin):
fields = ['site', 'name', 'slug']
class RackGroupFilterForm(forms.Form, BootstrapMixin):
class RackGroupFilterForm(BootstrapMixin, forms.Form):
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('rack_groups')), to_field_name='slug')
@@ -122,7 +125,7 @@ class RackGroupFilterForm(forms.Form, BootstrapMixin):
# Rack roles
#
class RackRoleForm(forms.ModelForm, BootstrapMixin):
class RackRoleForm(BootstrapMixin, forms.ModelForm):
slug = SlugField()
class Meta:
@@ -208,7 +211,7 @@ class RackFromCSVForm(forms.ModelForm):
))
class RackImportForm(BulkImportForm, BootstrapMixin):
class RackImportForm(BootstrapMixin, BulkImportForm):
csv = CSVDataField(csv_form=RackFromCSVForm)
@@ -221,7 +224,7 @@ class RackBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
type = forms.ChoiceField(choices=add_blank_choice(RACK_TYPE_CHOICES), required=False, label='Type')
width = forms.ChoiceField(choices=add_blank_choice(RACK_WIDTH_CHOICES), required=False, label='Width')
u_height = forms.IntegerField(required=False, label='Height (U)')
comments = CommentField()
comments = CommentField(widget=SmallTextarea)
class Meta:
nullable_fields = ['group', 'tenant', 'role', 'comments']
@@ -242,7 +245,7 @@ class RackFilterForm(BootstrapMixin, CustomFieldFilterForm):
# Manufacturers
#
class ManufacturerForm(forms.ModelForm, BootstrapMixin):
class ManufacturerForm(BootstrapMixin, forms.ModelForm):
slug = SlugField()
class Meta:
@@ -254,22 +257,30 @@ class ManufacturerForm(forms.ModelForm, BootstrapMixin):
# Device types
#
class DeviceTypeForm(forms.ModelForm, BootstrapMixin):
class DeviceTypeForm(BootstrapMixin, CustomFieldForm):
slug = SlugField(slug_source='model')
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']
'is_pdu', 'is_network_device', 'subdevice_role', 'interface_ordering', 'comments']
labels = {
'interface_ordering': 'Order interfaces by',
}
class DeviceTypeBulkEditForm(BulkEditForm, BootstrapMixin):
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 = []
class DeviceTypeFilterForm(forms.Form, BootstrapMixin):
class DeviceTypeFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = DeviceType
manufacturer = FilterChoiceField(queryset=Manufacturer.objects.annotate(filter_count=Count('device_types')),
to_field_name='slug')
@@ -278,44 +289,76 @@ class DeviceTypeFilterForm(forms.Form, BootstrapMixin):
# Device component templates
#
class ConsolePortTemplateForm(forms.ModelForm, BootstrapMixin):
name_pattern = ExpandableNameField(label='Name')
class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = ConsolePortTemplate
fields = ['name_pattern']
fields = ['device_type', 'name']
widgets = {
'device_type': forms.HiddenInput(),
}
class ConsoleServerPortTemplateForm(forms.ModelForm, BootstrapMixin):
class ConsolePortTemplateCreateForm(BootstrapMixin, forms.Form):
name_pattern = ExpandableNameField(label='Name')
class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = ConsoleServerPortTemplate
fields = ['name_pattern']
fields = ['device_type', 'name']
widgets = {
'device_type': forms.HiddenInput(),
}
class PowerPortTemplateForm(forms.ModelForm, BootstrapMixin):
class ConsoleServerPortTemplateCreateForm(BootstrapMixin, forms.Form):
name_pattern = ExpandableNameField(label='Name')
class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = PowerPortTemplate
fields = ['name_pattern']
fields = ['device_type', 'name']
widgets = {
'device_type': forms.HiddenInput(),
}
class PowerOutletTemplateForm(forms.ModelForm, BootstrapMixin):
class PowerPortTemplateCreateForm(BootstrapMixin, forms.Form):
name_pattern = ExpandableNameField(label='Name')
class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = PowerOutletTemplate
fields = ['name_pattern']
fields = ['device_type', 'name']
widgets = {
'device_type': forms.HiddenInput(),
}
class InterfaceTemplateForm(forms.ModelForm, BootstrapMixin):
class PowerOutletTemplateCreateForm(BootstrapMixin, forms.Form):
name_pattern = ExpandableNameField(label='Name')
class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = InterfaceTemplate
fields = ['name_pattern', 'form_factor', 'mgmt_only']
fields = ['device_type', 'name', 'form_factor', 'mgmt_only']
widgets = {
'device_type': forms.HiddenInput(),
}
class InterfaceTemplateCreateForm(BootstrapMixin, forms.Form):
name_pattern = ExpandableNameField(label='Name')
form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES)
mgmt_only = forms.BooleanField(required=False, label='OOB Management')
class InterfaceTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
@@ -326,19 +369,25 @@ class InterfaceTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
nullable_fields = []
class DeviceBayTemplateForm(forms.ModelForm, BootstrapMixin):
name_pattern = ExpandableNameField(label='Name')
class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = DeviceBayTemplate
fields = ['name_pattern']
fields = ['device_type', 'name']
widgets = {
'device_type': forms.HiddenInput(),
}
class DeviceBayTemplateCreateForm(BootstrapMixin, forms.Form):
name_pattern = ExpandableNameField(label='Name')
#
# Device roles
#
class DeviceRoleForm(forms.ModelForm, BootstrapMixin):
class DeviceRoleForm(BootstrapMixin, forms.ModelForm):
slug = SlugField()
class Meta:
@@ -350,7 +399,7 @@ class DeviceRoleForm(forms.ModelForm, BootstrapMixin):
# Platforms
#
class PlatformForm(forms.ModelForm, BootstrapMixin):
class PlatformForm(BootstrapMixin, forms.ModelForm):
slug = SlugField()
class Meta:
@@ -414,6 +463,10 @@ class DeviceForm(BootstrapMixin, CustomFieldForm):
ip_choices += [(ip.id, u'{} ({} NAT)'.format(ip.address, ip.nat_inside.interface)) for ip in nat_ips]
self.fields['primary_ip{}'.format(family)].choices = [(None, '---------')] + ip_choices
# If editing an existing device, exclude it from the list of occupied rack units. This ensures that a device
# can be flipped from one face to another.
self.fields['position'].widget.attrs['api-url'] += '&exclude={}'.format(self.instance.pk)
else:
# An object that doesn't exist yet can't have any IPs assigned to it
@@ -563,11 +616,11 @@ class ChildDeviceFromCSVForm(BaseDeviceFromCSVForm):
self.add_error('device_bay_name', "Parent device/bay ({} {}) not found".format(parent, device_bay_name))
class DeviceImportForm(BulkImportForm, BootstrapMixin):
class DeviceImportForm(BootstrapMixin, BulkImportForm):
csv = CSVDataField(csv_form=DeviceFromCSVForm)
class ChildDeviceImportForm(BulkImportForm, BootstrapMixin):
class ChildDeviceImportForm(BootstrapMixin, BulkImportForm):
csv = CSVDataField(csv_form=ChildDeviceFromCSVForm)
@@ -584,18 +637,6 @@ class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
nullable_fields = ['tenant', 'platform']
class DeviceBulkAddComponentForm(forms.Form, BootstrapMixin):
pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput)
name_pattern = ExpandableNameField(label='Name')
class DeviceBulkAddInterfaceForm(forms.ModelForm, DeviceBulkAddComponentForm):
class Meta:
model = Interface
fields = ['name_pattern', 'form_factor', 'mgmt_only', 'description']
class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Device
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('racks__devices')), to_field_name='slug')
@@ -609,13 +650,30 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
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')
#
# Bulk device component creation
#
class DeviceBulkAddComponentForm(BootstrapMixin, forms.Form):
pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput)
name_pattern = ExpandableNameField(label='Name')
class DeviceBulkAddInterfaceForm(forms.ModelForm, DeviceBulkAddComponentForm):
class Meta:
model = Interface
fields = ['pk', 'name_pattern', 'form_factor', 'mgmt_only', 'description']
#
# Console ports
#
class ConsolePortForm(forms.ModelForm, BootstrapMixin):
class ConsolePortForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = ConsolePort
@@ -625,7 +683,7 @@ class ConsolePortForm(forms.ModelForm, BootstrapMixin):
}
class ConsolePortCreateForm(forms.Form, BootstrapMixin):
class ConsolePortCreateForm(BootstrapMixin, forms.Form):
name_pattern = ExpandableNameField(label='Name')
@@ -666,7 +724,7 @@ class ConsoleConnectionCSVForm(forms.Form):
.format(self.cleaned_data['device'], self.cleaned_data['console_port']))
class ConsoleConnectionImportForm(BulkImportForm, BootstrapMixin):
class ConsoleConnectionImportForm(BootstrapMixin, BulkImportForm):
csv = CSVDataField(csv_form=ConsoleConnectionCSVForm)
def clean(self):
@@ -696,7 +754,7 @@ class ConsoleConnectionImportForm(BulkImportForm, BootstrapMixin):
self.cleaned_data['csv'] = connection_list
class ConsolePortConnectionForm(forms.ModelForm, BootstrapMixin):
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,
@@ -750,7 +808,7 @@ class ConsolePortConnectionForm(forms.ModelForm, BootstrapMixin):
# Console server ports
#
class ConsoleServerPortForm(forms.ModelForm, BootstrapMixin):
class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = ConsoleServerPort
@@ -760,11 +818,11 @@ class ConsoleServerPortForm(forms.ModelForm, BootstrapMixin):
}
class ConsoleServerPortCreateForm(forms.Form, BootstrapMixin):
class ConsoleServerPortCreateForm(BootstrapMixin, forms.Form):
name_pattern = ExpandableNameField(label='Name')
class ConsoleServerPortConnectionForm(forms.Form, BootstrapMixin):
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,
@@ -812,7 +870,7 @@ class ConsoleServerPortConnectionForm(forms.Form, BootstrapMixin):
# Power ports
#
class PowerPortForm(forms.ModelForm, BootstrapMixin):
class PowerPortForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = PowerPort
@@ -822,7 +880,7 @@ class PowerPortForm(forms.ModelForm, BootstrapMixin):
}
class PowerPortCreateForm(forms.Form, BootstrapMixin):
class PowerPortCreateForm(BootstrapMixin, forms.Form):
name_pattern = ExpandableNameField(label='Name')
@@ -863,7 +921,7 @@ class PowerConnectionCSVForm(forms.Form):
.format(self.cleaned_data['device'], self.cleaned_data['power_port']))
class PowerConnectionImportForm(BulkImportForm, BootstrapMixin):
class PowerConnectionImportForm(BootstrapMixin, BulkImportForm):
csv = CSVDataField(csv_form=PowerConnectionCSVForm)
def clean(self):
@@ -893,7 +951,7 @@ class PowerConnectionImportForm(BulkImportForm, BootstrapMixin):
self.cleaned_data['csv'] = connection_list
class PowerPortConnectionForm(forms.ModelForm, BootstrapMixin):
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,
@@ -946,7 +1004,7 @@ class PowerPortConnectionForm(forms.ModelForm, BootstrapMixin):
# Power outlets
#
class PowerOutletForm(forms.ModelForm, BootstrapMixin):
class PowerOutletForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = PowerOutlet
@@ -956,11 +1014,11 @@ class PowerOutletForm(forms.ModelForm, BootstrapMixin):
}
class PowerOutletCreateForm(forms.Form, BootstrapMixin):
class PowerOutletCreateForm(BootstrapMixin, forms.Form):
name_pattern = ExpandableNameField(label='Name')
class PowerOutletConnectionForm(forms.Form, BootstrapMixin):
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,
@@ -1008,7 +1066,7 @@ class PowerOutletConnectionForm(forms.Form, BootstrapMixin):
# Interfaces
#
class InterfaceForm(forms.ModelForm, BootstrapMixin):
class InterfaceForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = Interface
@@ -1018,12 +1076,12 @@ class InterfaceForm(forms.ModelForm, BootstrapMixin):
}
class InterfaceCreateForm(forms.ModelForm, BootstrapMixin):
class InterfaceCreateForm(BootstrapMixin, forms.Form):
name_pattern = ExpandableNameField(label='Name')
class Meta:
model = Interface
fields = ['name_pattern', 'form_factor', 'mac_address', 'mgmt_only', 'description']
form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES)
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)
class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
@@ -1039,10 +1097,13 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
# Interface connections
#
class InterfaceConnectionForm(forms.ModelForm, BootstrapMixin):
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=forms.Select(attrs={'filter-for': 'device_b'}))
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',
@@ -1056,21 +1117,27 @@ class InterfaceConnectionForm(forms.ModelForm, BootstrapMixin):
class Meta:
model = InterfaceConnection
fields = ['interface_a', 'rack_b', 'device_b', 'interface_b', 'livesearch', 'connection_status']
fields = ['interface_a', 'site_b', 'rack_b', 'device_b', 'interface_b', 'livesearch', 'connection_status']
def __init__(self, device_a, *args, **kwargs):
super(InterfaceConnectionForm, self).__init__(*args, **kwargs)
self.fields['rack_b'].queryset = Rack.objects.filter(site=device_a.rack.site)
# Initialize interface A choices
device_a_interfaces = Interface.objects.filter(device=device_a).exclude(form_factor=IFACE_FF_VIRTUAL) \
.select_related('circuit', 'connected_as_a', 'connected_as_b')
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')
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'):
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'])
@@ -1081,11 +1148,13 @@ class InterfaceConnectionForm(forms.ModelForm, BootstrapMixin):
# 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', 'connected_as_a', 'connected_as_b')
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', 'connected_as_a', 'connected_as_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')
else:
device_b_interfaces = []
self.fields['interface_b'].choices = [
@@ -1135,7 +1204,7 @@ class InterfaceConnectionCSVForm(forms.Form):
pass
class InterfaceConnectionImportForm(BulkImportForm, BootstrapMixin):
class InterfaceConnectionImportForm(BootstrapMixin, BulkImportForm):
csv = CSVDataField(csv_form=InterfaceConnectionCSVForm)
def clean(self):
@@ -1175,7 +1244,7 @@ class InterfaceConnectionImportForm(BulkImportForm, BootstrapMixin):
self.cleaned_data['csv'] = connection_list
class InterfaceConnectionDeletionForm(forms.Form, BootstrapMixin):
class InterfaceConnectionDeletionForm(BootstrapMixin, forms.Form):
confirm = forms.BooleanField(required=True)
# Used for HTTP redirect upon successful deletion
device = forms.ModelChoiceField(queryset=Device.objects.all(), widget=forms.HiddenInput(), required=False)
@@ -1185,7 +1254,7 @@ class InterfaceConnectionDeletionForm(forms.Form, BootstrapMixin):
# Device bays
#
class DeviceBayForm(forms.ModelForm, BootstrapMixin):
class DeviceBayForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = DeviceBay
@@ -1195,11 +1264,11 @@ class DeviceBayForm(forms.ModelForm, BootstrapMixin):
}
class DeviceBayCreateForm(forms.Form, BootstrapMixin):
class DeviceBayCreateForm(BootstrapMixin, forms.Form):
name_pattern = ExpandableNameField(label='Name')
class PopulateDeviceBayForm(forms.Form, BootstrapMixin):
class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
installed_device = forms.ModelChoiceField(queryset=Device.objects.all(), label='Child Device',
help_text="Child devices must first be created within the rack occupied "
"by the parent device. Then they can be assigned to a bay.")
@@ -1220,15 +1289,15 @@ class PopulateDeviceBayForm(forms.Form, BootstrapMixin):
# Connections
#
class ConsoleConnectionFilterForm(forms.Form, BootstrapMixin):
class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form):
site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug')
class PowerConnectionFilterForm(forms.Form, BootstrapMixin):
class PowerConnectionFilterForm(BootstrapMixin, forms.Form):
site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug')
class InterfaceConnectionFilterForm(forms.Form, BootstrapMixin):
class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form):
site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug')
@@ -1249,10 +1318,15 @@ class IPAddressForm(BootstrapMixin, CustomFieldForm):
self.fields['vrf'].empty_label = 'Global'
self.fields['interface'].queryset = device.interfaces.all()
interfaces = device.interfaces.all()
self.fields['interface'].queryset = interfaces
self.fields['interface'].required = True
# If this device does not have any IP addresses assigned, default to setting the first IP as its primary
# If this device has only one interface, select it by default.
if len(interfaces) == 1:
self.fields['interface'].initial = interfaces[0]
# If this device does not have any IP addresses assigned, default to setting the first IP as its primary.
if not IPAddress.objects.filter(interface__device=device).count():
self.fields['set_as_primary'].initial = True
@@ -1261,7 +1335,7 @@ class IPAddressForm(BootstrapMixin, CustomFieldForm):
# Modules
#
class ModuleForm(forms.ModelForm, BootstrapMixin):
class ModuleForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = Module

View File

@@ -0,0 +1,57 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10 on 2016-12-06 16:35
from __future__ import unicode_literals
from django.db import migrations
import utilities.fields
COLOR_CONVERSION = {
'teal': '009688',
'green': '4caf50',
'blue': '2196f3',
'purple': '9c27b0',
'yellow': 'ffeb3b',
'orange': 'ff9800',
'red': 'f44336',
'light_gray': 'c0c0c0',
'medium_gray': '9e9e9e',
'dark_gray': '607d8b',
}
def color_names_to_rgb(apps, schema_editor):
RackRole = apps.get_model('dcim', 'RackRole')
DeviceRole = apps.get_model('dcim', 'DeviceRole')
for color_name, color_rgb in COLOR_CONVERSION.items():
RackRole.objects.filter(color=color_name).update(color=color_rgb)
DeviceRole.objects.filter(color=color_name).update(color=color_rgb)
def color_rgb_to_name(apps, schema_editor):
RackRole = apps.get_model('dcim', 'RackRole')
DeviceRole = apps.get_model('dcim', 'DeviceRole')
for color_name, color_rgb in COLOR_CONVERSION.items():
RackRole.objects.filter(color=color_rgb).update(color=color_name)
DeviceRole.objects.filter(color=color_rgb).update(color=color_name)
class Migration(migrations.Migration):
dependencies = [
('dcim', '0021_add_ff_flexstack'),
]
operations = [
migrations.RunPython(color_names_to_rgb, color_rgb_to_name),
migrations.AlterField(
model_name='devicerole',
name='color',
field=utilities.fields.ColorField(max_length=6),
),
migrations.AlterField(
model_name='rackrole',
name='color',
field=utilities.fields.ColorField(max_length=6),
),
]

View File

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10 on 2016-12-16 16:08
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0022_color_names_to_rgb'),
]
operations = [
migrations.AddField(
model_name='devicetype',
name='comments',
field=models.TextField(blank=True),
),
]

View File

@@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2016-12-29 16:23
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0023_devicetype_comments'),
]
operations = [
migrations.AddField(
model_name='site',
name='contact_email',
field=models.EmailField(blank=True, max_length=254, verbose_name=b'Contact E-mail'),
),
migrations.AddField(
model_name='site',
name='contact_name',
field=models.CharField(blank=True, max_length=50),
),
migrations.AddField(
model_name='site',
name='contact_phone',
field=models.CharField(blank=True, max_length=20),
),
]

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

@@ -3,18 +3,20 @@ from collections import OrderedDict
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import MultipleObjectsReturned, ValidationError
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 circuits.models import Circuit
from extras.models import CustomFieldModel, CustomField, CustomFieldValue
from extras.rpc import RPC_CLIENTS
from tenancy.models import Tenant
from utilities.fields import NullableCharField
from utilities.fields import ColorField, NullableCharField
from utilities.managers import NaturalOrderByManager
from utilities.models import CreatedUpdatedModel
from utilities.utils import csv_format
from .fields import ASNField, MACAddressField
@@ -54,27 +56,11 @@ SUBDEVICE_ROLE_CHOICES = (
(SUBDEVICE_ROLE_CHILD, 'Child'),
)
COLOR_TEAL = 'teal'
COLOR_GREEN = 'green'
COLOR_BLUE = 'blue'
COLOR_PURPLE = 'purple'
COLOR_YELLOW = 'yellow'
COLOR_ORANGE = 'orange'
COLOR_RED = 'red'
COLOR_GRAY1 = 'light_gray'
COLOR_GRAY2 = 'medium_gray'
COLOR_GRAY3 = 'dark_gray'
ROLE_COLOR_CHOICES = [
[COLOR_TEAL, 'Teal'],
[COLOR_GREEN, 'Green'],
[COLOR_BLUE, 'Blue'],
[COLOR_PURPLE, 'Purple'],
[COLOR_YELLOW, 'Yellow'],
[COLOR_ORANGE, 'Orange'],
[COLOR_RED, 'Red'],
[COLOR_GRAY1, 'Light Gray'],
[COLOR_GRAY2, 'Medium Gray'],
[COLOR_GRAY3, 'Dark Gray'],
IFACE_ORDERING_POSITION = 1
IFACE_ORDERING_NAME = 2
IFACE_ORDERING_CHOICES = [
[IFACE_ORDERING_POSITION, 'Slot/position'],
[IFACE_ORDERING_NAME, 'Name (alphabetically)']
]
# Virtual
@@ -203,48 +189,6 @@ RPC_CLIENT_CHOICES = [
]
def order_interfaces(queryset, sql_col, primary_ordering=tuple()):
"""
Attempt to match interface names by their slot/position identifiers and order according. Matching is done using the
following pattern:
{a}/{b}/{c}:{d}
Interfaces are ordered first by field a, then b, then c, and finally d. Leading text (which typically indicates the
interface's type) is ignored. If any fields are not contained by an interface name, those fields are treated as
None. 'None' is ordered after all other values. For example:
et-0/0/0
et-0/0/1
et-0/1/0
xe-0/1/1:0
xe-0/1/1:1
xe-0/1/1:2
xe-0/1/1:3
et-0/1/2
...
et-0/1/9
et-0/1/10
et-0/1/11
et-1/0/0
et-1/0/1
...
vlan1
vlan10
:param queryset: The base queryset to be ordered
:param sql_col: Table and name of the SQL column which contains the interface name (ex: ''dcim_interface.name')
:param primary_ordering: A tuple of fields which take ordering precedence before the interface name (optional)
"""
ordering = primary_ordering + ('_id1', '_id2', '_id3', '_id4')
return queryset.extra(select={
'_id1': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+\/[0-9]+(:[0-9]+)?$') AS integer)".format(sql_col),
'_id2': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+(:[0-9]+)?$') AS integer)".format(sql_col),
'_id3': "CAST(SUBSTRING({} FROM '([0-9]+)(:[0-9]+)?$') AS integer)".format(sql_col),
'_id4': "CAST(SUBSTRING({} FROM ':([0-9]+)$') AS integer)".format(sql_col),
}).order_by(*ordering)
#
# Sites
#
@@ -267,6 +211,9 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
asn = ASNField(blank=True, null=True, verbose_name='ASN')
physical_address = models.CharField(max_length=200, blank=True)
shipping_address = models.CharField(max_length=200, blank=True)
contact_name = models.CharField(max_length=50, blank=True)
contact_phone = models.CharField(max_length=20, blank=True)
contact_email = models.EmailField(blank=True, verbose_name="Contact E-mail")
comments = models.TextField(blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
@@ -282,12 +229,15 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
return reverse('dcim:site', args=[self.slug])
def to_csv(self):
return ','.join([
return csv_format([
self.name,
self.slug,
self.tenant.name if self.tenant else '',
self.tenant.name if self.tenant else None,
self.facility,
str(self.asn),
self.asn,
self.contact_name,
self.contact_phone,
self.contact_email,
])
@property
@@ -308,7 +258,7 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
@property
def count_circuits(self):
return self.circuits.count()
return Circuit.objects.filter(terminations__site=self).count()
#
@@ -345,7 +295,7 @@ class RackRole(models.Model):
"""
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
color = models.CharField(max_length=30, choices=ROLE_COLOR_CHOICES)
color = ColorField()
class Meta:
ordering = ['name']
@@ -414,16 +364,17 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
})
def to_csv(self):
return ','.join([
return csv_format([
self.site.name,
self.group.name if self.group else '',
self.group.name if self.group else None,
self.name,
self.facility_id or '',
self.tenant.name if self.tenant else '',
self.role.name if self.role else '',
self.get_type_display() if self.type else '',
str(self.width),
str(self.u_height),
self.facility_id,
self.tenant.name if self.tenant else None,
self.role.name if self.role else None,
self.get_type_display() if self.type else None,
self.width,
self.u_height,
self.desc_units,
])
@property
@@ -543,7 +494,7 @@ class Manufacturer(models.Model):
return "{}?manufacturer={}".format(reverse('dcim:devicetype_list'), self.slug)
class DeviceType(models.Model):
class DeviceType(models.Model, CustomFieldModel):
"""
A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as
well as high-level functional role(s).
@@ -565,6 +516,8 @@ class DeviceType(models.Model):
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',
@@ -575,6 +528,8 @@ class DeviceType(models.Model):
choices=SUBDEVICE_ROLE_CHOICES,
help_text="Parent devices house child devices in device bays. Select "
"\"None\" if this device type is neither a parent nor a child.")
comments = models.TextField(blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
class Meta:
ordering = ['manufacturer', 'model']
@@ -584,7 +539,7 @@ class DeviceType(models.Model):
]
def __unicode__(self):
return u'{} {}'.format(self.manufacturer, self.model)
return self.model
def __init__(self, *args, **kwargs):
super(DeviceType, self).__init__(*args, **kwargs)
@@ -640,6 +595,10 @@ class DeviceType(models.Model):
'u_height': "Child device types must be 0U."
})
@property
def full_name(self):
return u'{} {}'.format(self.manufacturer.name, self.model)
@property
def is_parent_device(self):
return bool(self.subdevice_role)
@@ -709,11 +668,42 @@ class PowerOutletTemplate(models.Model):
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)
class InterfaceTemplate(models.Model):
@@ -725,7 +715,7 @@ 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']
@@ -761,7 +751,7 @@ class DeviceRole(models.Model):
"""
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
color = models.CharField(max_length=30, choices=ROLE_COLOR_CHOICES)
color = ColorField()
class Meta:
ordering = ['name']
@@ -852,8 +842,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
'face': "Must specify rack face when defining rack position."
})
if self.device_type:
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({
@@ -880,6 +869,9 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
except Rack.DoesNotExist:
pass
except DeviceType.DoesNotExist:
pass
def save(self, *args, **kwargs):
is_new = not bool(self.pk)
@@ -917,19 +909,19 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
Device.objects.filter(parent_bay__device=self).update(rack=self.rack)
def to_csv(self):
return ','.join([
return csv_format([
self.name or '',
self.device_role.name,
self.tenant.name if self.tenant else '',
self.tenant.name if self.tenant else None,
self.device_type.manufacturer.name,
self.device_type.model,
self.platform.name if self.platform else '',
self.platform.name if self.platform else None,
self.serial,
self.asset_tag if self.asset_tag else '',
self.asset_tag,
self.rack.site.name,
self.rack.name,
str(self.position) if self.position else '',
self.get_face_display() or '',
self.position,
self.get_face_display(),
])
@property
@@ -993,14 +985,11 @@ class ConsolePort(models.Model):
def __unicode__(self):
return self.name
def get_parent_url(self):
return self.device.get_absolute_url()
# Used for connections export
def to_csv(self):
return ','.join([
self.cs_port.device.identifier if self.cs_port else '',
self.cs_port.name if self.cs_port else '',
return csv_format([
self.cs_port.device.identifier if self.cs_port else None,
self.cs_port.name if self.cs_port else None,
self.device.identifier,
self.name,
self.get_connection_status_display(),
@@ -1037,9 +1026,6 @@ class ConsoleServerPort(models.Model):
def __unicode__(self):
return self.name
def get_parent_url(self):
return self.device.get_absolute_url()
class PowerPort(models.Model):
"""
@@ -1058,14 +1044,11 @@ class PowerPort(models.Model):
def __unicode__(self):
return self.name
def get_parent_url(self):
return self.device.get_absolute_url()
# Used for connections export
def to_csv(self):
def csv_format(self):
return ','.join([
self.power_outlet.device.identifier if self.power_outlet else '',
self.power_outlet.name if self.power_outlet else '',
self.power_outlet.device.identifier if self.power_outlet else None,
self.power_outlet.name if self.power_outlet else None,
self.device.identifier,
self.name,
self.get_connection_status_display(),
@@ -1096,22 +1079,6 @@ class PowerOutlet(models.Model):
def __unicode__(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)
class Interface(models.Model):
"""
@@ -1135,9 +1102,6 @@ class Interface(models.Model):
def __unicode__(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:
@@ -1153,7 +1117,7 @@ class Interface(models.Model):
@property
def is_connected(self):
try:
return bool(self.circuit)
return bool(self.circuit_termination)
except ObjectDoesNotExist:
pass
return bool(self.connection)
@@ -1170,17 +1134,19 @@ class Interface(models.Model):
pass
return None
def get_connected_interface(self):
@property
def connected_interface(self):
try:
connection = InterfaceConnection.objects.select_related().get(Q(interface_a=self) | Q(interface_b=self))
if connection.interface_a == self:
return connection.interface_b
else:
return connection.interface_a
except InterfaceConnection.DoesNotExist:
return None
except InterfaceConnection.MultipleObjectsReturned:
raise MultipleObjectsReturned("Multiple connections found for {} interface {}!".format(self.device, self))
if self.connected_as_a:
return self.connected_as_a.interface_b
except ObjectDoesNotExist:
pass
try:
if self.connected_as_b:
return self.connected_as_b.interface_a
except ObjectDoesNotExist:
pass
return None
class InterfaceConnection(models.Model):
@@ -1201,7 +1167,7 @@ class InterfaceConnection(models.Model):
# Used for connections export
def to_csv(self):
return ','.join([
return csv_format([
self.interface_a.device.identifier,
self.interface_a.name,
self.interface_b.device.identifier,
@@ -1226,9 +1192,6 @@ class DeviceBay(models.Model):
def __unicode__(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
@@ -1262,6 +1225,3 @@ class Module(models.Model):
def __unicode__(self):
return self.name
def get_parent_url(self):
return reverse('dcim:device_inventory', args=[self.device.pk])

View File

@@ -11,7 +11,7 @@ from .models import (
COLOR_LABEL = """
<label class="label {{ record.color }}">{{ record }}</label>
<label class="label" style="background-color: #{{ record.color }}">{{ record }}</label>
"""
DEVICE_LINK = """
@@ -34,7 +34,7 @@ RACKROLE_ACTIONS = """
RACK_ROLE = """
{% if record.role %}
<label class="label {{ record.role.color }}">{{ value }}</label>
<label class="label" style="background-color: #{{ record.role.color }}">{{ value }}</label>
{% else %}
&mdash;
{% endif %}
@@ -59,7 +59,7 @@ PLATFORM_ACTIONS = """
"""
DEVICE_ROLE = """
<label class="label {{ record.device_role.color }}">{{ value }}</label>
<label class="label" style="background-color: #{{ record.device_role.color }}">{{ value }}</label>
"""
STATUS_ICON = """
@@ -294,7 +294,8 @@ class PlatformTable(BaseTable):
name = tables.LinkColumn(verbose_name='Name')
device_count = tables.Column(verbose_name='Devices')
slug = tables.Column(verbose_name='Slug')
actions = tables.TemplateColumn(template_code=PLATFORM_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name='')
actions = tables.TemplateColumn(template_code=PLATFORM_ACTIONS, attrs={'td': {'class': 'text-right'}},
verbose_name='')
class Meta(BaseTable.Meta):
model = Platform
@@ -310,10 +311,12 @@ 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', accessor=Accessor('rack.site'), args=[Accessor('rack.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.Column(verbose_name='Type')
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 }}")
@@ -325,7 +328,8 @@ 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', accessor=Accessor('rack.site'), args=[Accessor('rack.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

@@ -22,6 +22,9 @@ class SiteTest(APITestCase):
'asn',
'physical_address',
'shipping_address',
'contact_name',
'contact_phone',
'contact_email',
'comments',
'custom_fields',
'count_prefixes',
@@ -229,10 +232,13 @@ 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',
]
nested_fields = [

View File

@@ -1,5 +1,6 @@
from django.conf.urls import url
from ipam.views import ServiceEditView
from secrets.views import secret_add
from . import views
@@ -104,9 +105,11 @@ urlpatterns = [
url(r'^devices/(?P<pk>\d+)/lldp-neighbors/$', views.device_lldp_neighbors, name='device_lldp_neighbors'),
url(r'^devices/(?P<pk>\d+)/ip-addresses/assign/$', views.ipaddress_assign, name='ipaddress_assign'),
url(r'^devices/(?P<pk>\d+)/add-secret/$', secret_add, name='device_addsecret'),
url(r'^devices/(?P<device>\d+)/services/assign/$', ServiceEditView.as_view(), name='service_assign'),
# Console ports
url(r'^devices/(?P<pk>\d+)/console-ports/add/$', views.consoleport_add, name='consoleport_add'),
url(r'^devices/console-ports/add/$', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),
url(r'^devices/(?P<pk>\d+)/console-ports/add/$', views.ConsolePortAddView.as_view(), name='consoleport_add'),
url(r'^devices/(?P<pk>\d+)/console-ports/delete/$', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'),
url(r'^console-ports/(?P<pk>\d+)/connect/$', views.consoleport_connect, name='consoleport_connect'),
url(r'^console-ports/(?P<pk>\d+)/disconnect/$', views.consoleport_disconnect, name='consoleport_disconnect'),
@@ -114,7 +117,8 @@ urlpatterns = [
url(r'^console-ports/(?P<pk>\d+)/delete/$', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'),
# Console server ports
url(r'^devices/(?P<pk>\d+)/console-server-ports/add/$', views.consoleserverport_add, name='consoleserverport_add'),
url(r'^devices/console-server-ports/add/$', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'),
url(r'^devices/(?P<pk>\d+)/console-server-ports/add/$', views.ConsoleServerPortAddView.as_view(), name='consoleserverport_add'),
url(r'^devices/(?P<pk>\d+)/console-server-ports/delete/$', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'),
url(r'^console-server-ports/(?P<pk>\d+)/connect/$', views.consoleserverport_connect, name='consoleserverport_connect'),
url(r'^console-server-ports/(?P<pk>\d+)/disconnect/$', views.consoleserverport_disconnect, name='consoleserverport_disconnect'),
@@ -122,7 +126,8 @@ urlpatterns = [
url(r'^console-server-ports/(?P<pk>\d+)/delete/$', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'),
# Power ports
url(r'^devices/(?P<pk>\d+)/power-ports/add/$', views.powerport_add, name='powerport_add'),
url(r'^devices/power-ports/add/$', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'),
url(r'^devices/(?P<pk>\d+)/power-ports/add/$', views.PowerPortAddView.as_view(), name='powerport_add'),
url(r'^devices/(?P<pk>\d+)/power-ports/delete/$', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'),
url(r'^power-ports/(?P<pk>\d+)/connect/$', views.powerport_connect, name='powerport_connect'),
url(r'^power-ports/(?P<pk>\d+)/disconnect/$', views.powerport_disconnect, name='powerport_disconnect'),
@@ -130,15 +135,27 @@ urlpatterns = [
url(r'^power-ports/(?P<pk>\d+)/delete/$', views.PowerPortDeleteView.as_view(), name='powerport_delete'),
# Power outlets
url(r'^devices/(?P<pk>\d+)/power-outlets/add/$', views.poweroutlet_add, name='poweroutlet_add'),
url(r'^devices/power-outlets/add/$', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'),
url(r'^devices/(?P<pk>\d+)/power-outlets/add/$', views.PowerOutletAddView.as_view(), name='poweroutlet_add'),
url(r'^devices/(?P<pk>\d+)/power-outlets/delete/$', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'),
url(r'^power-outlets/(?P<pk>\d+)/connect/$', views.poweroutlet_connect, name='poweroutlet_connect'),
url(r'^power-outlets/(?P<pk>\d+)/disconnect/$', views.poweroutlet_disconnect, name='poweroutlet_disconnect'),
url(r'^power-outlets/(?P<pk>\d+)/edit/$', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'),
url(r'^power-outlets/(?P<pk>\d+)/delete/$', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'),
# Interfaces
url(r'^devices/interfaces/add/$', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'),
url(r'^devices/(?P<pk>\d+)/interfaces/add/$', views.InterfaceAddView.as_view(), name='interface_add'),
url(r'^devices/(?P<pk>\d+)/interfaces/edit/$', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'),
url(r'^devices/(?P<pk>\d+)/interfaces/delete/$', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
url(r'^devices/(?P<pk>\d+)/interface-connections/add/$', views.interfaceconnection_add, name='interfaceconnection_add'),
url(r'^interface-connections/(?P<pk>\d+)/delete/$', views.interfaceconnection_delete, name='interfaceconnection_delete'),
url(r'^interfaces/(?P<pk>\d+)/edit/$', views.InterfaceEditView.as_view(), name='interface_edit'),
url(r'^interfaces/(?P<pk>\d+)/delete/$', views.InterfaceDeleteView.as_view(), name='interface_delete'),
# Device bays
url(r'^devices/(?P<pk>\d+)/bays/add/$', views.devicebay_add, name='devicebay_add'),
url(r'^devices/device-bays/add/$', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'),
url(r'^devices/(?P<pk>\d+)/bays/add/$', views.DeviceBayAddView.as_view(), name='devicebay_add'),
url(r'^devices/(?P<pk>\d+)/bays/delete/$', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'),
url(r'^device-bays/(?P<pk>\d+)/edit/$', views.DeviceBayEditView.as_view(), name='devicebay_edit'),
url(r'^device-bays/(?P<pk>\d+)/delete/$', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'),
@@ -153,18 +170,8 @@ urlpatterns = [
url(r'^interface-connections/$', views.InterfaceConnectionsListView.as_view(), name='interface_connections_list'),
url(r'^interface-connections/import/$', views.InterfaceConnectionsBulkImportView.as_view(), name='interface_connections_import'),
# Interfaces
url(r'^devices/interfaces/add/$', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'),
url(r'^devices/(?P<pk>\d+)/interfaces/add/$', views.interface_add, name='interface_add'),
url(r'^devices/(?P<pk>\d+)/interfaces/edit/$', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'),
url(r'^devices/(?P<pk>\d+)/interfaces/delete/$', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
url(r'^devices/(?P<pk>\d+)/interface-connections/add/$', views.interfaceconnection_add, name='interfaceconnection_add'),
url(r'^interface-connections/(?P<pk>\d+)/delete/$', views.interfaceconnection_delete, name='interfaceconnection_delete'),
url(r'^interfaces/(?P<pk>\d+)/edit/$', views.InterfaceEditView.as_view(), name='interface_edit'),
url(r'^interfaces/(?P<pk>\d+)/delete/$', views.InterfaceDeleteView.as_view(), name='interface_delete'),
# Modules
url(r'^devices/(?P<pk>\d+)/modules/add/$', views.module_add, name='module_add'),
url(r'^devices/(?P<device>\d+)/modules/add/$', views.ModuleEditView.as_view(), name='module_add'),
url(r'^modules/(?P<pk>\d+)/edit/$', views.ModuleEditView.as_view(), name='module_edit'),
url(r'^modules/(?P<pk>\d+)/delete/$', views.ModuleDeleteView.as_view(), name='module_delete'),

View File

@@ -6,16 +6,14 @@ from operator import attrgetter
from django.contrib import messages
from django.contrib.auth.decorators import permission_required
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.exceptions import ValidationError
from django.core.urlresolvers import reverse
from django.db import transaction
from django.db.models import Count
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect, render
from django.utils.http import urlencode
from django.views.generic import View
from ipam.models import Prefix, IPAddress, VLAN
from ipam.models import Prefix, IPAddress, Service, VLAN
from circuits.models import Circuit
from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
from utilities.forms import ConfirmationForm
@@ -58,6 +56,66 @@ def expand_pattern(string):
yield "{0}{1}".format(lead, i)
class ComponentCreateView(View):
parent_model = None
parent_field = None
model = None
form = None
model_form = None
def get(self, request, pk):
parent = get_object_or_404(self.parent_model, pk=pk)
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(),
})
def post(self, request, pk):
parent = get_object_or_404(self.parent_model, pk=pk)
form = self.form(request.POST)
if form.is_valid():
new_components = []
data = deepcopy(form.cleaned_data)
for name in form.cleaned_data['name_pattern']:
component_data = {
self.parent_field: parent.pk,
'name': name,
}
component_data.update(data)
component_form = self.model_form(component_data)
if component_form.is_valid():
new_components.append(component_form.save(commit=False))
else:
for field, errors in component_form.errors.as_data().items():
for e in errors:
form.add_error(field, u'{}: {}'.format(name, ', '.join(e)))
if not form.errors:
self.model.objects.bulk_create(new_components)
messages.success(request, u"Added {} {} to {}.".format(
len(new_components), self.model._meta.verbose_name_plural, parent
))
if '_addanother' in request.POST:
return redirect(request.path)
else:
return redirect(parent.get_absolute_url())
return render(request, 'dcim/device_component_add.html', {
'parent': parent,
'component_type': self.model._meta.verbose_name,
'form': form,
'cancel_url': parent.get_absolute_url(),
})
#
# Sites
#
@@ -79,7 +137,7 @@ def site(request, slug):
'device_count': Device.objects.filter(rack__site=site).count(),
'prefix_count': Prefix.objects.filter(site=site).count(),
'vlan_count': VLAN.objects.filter(site=site).count(),
'circuit_count': Circuit.objects.filter(site=site).count(),
'circuit_count': Circuit.objects.filter(terminations__site=site).count(),
}
rack_groups = RackGroup.objects.filter(site=site).annotate(rack_count=Count('racks'))
topology_maps = TopologyMap.objects.filter(site=site)
@@ -99,13 +157,13 @@ class SiteEditView(PermissionRequiredMixin, ObjectEditView):
model = Site
form_class = forms.SiteForm
template_name = 'dcim/site_edit.html'
cancel_url = 'dcim:site_list'
obj_list_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):
@@ -141,8 +199,9 @@ class RackGroupEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_rackgroup'
model = RackGroup
form_class = forms.RackGroupForm
success_url = 'dcim:rackgroup_list'
cancel_url = 'dcim:rackgroup_list'
def get_return_url(self, obj):
return reverse('dcim:rackgroup_list')
class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -166,8 +225,9 @@ class RackRoleEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_rackrole'
model = RackRole
form_class = forms.RackRoleForm
success_url = 'dcim:rackrole_list'
cancel_url = 'dcim:rackrole_list'
def get_return_url(self, obj):
return reverse('dcim:rackrole_list')
class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -214,13 +274,13 @@ class RackEditView(PermissionRequiredMixin, ObjectEditView):
model = Rack
form_class = forms.RackForm
template_name = 'dcim/rack_edit.html'
cancel_url = 'dcim:rack_list'
obj_list_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):
@@ -260,8 +320,9 @@ class ManufacturerEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_manufacturer'
model = Manufacturer
form_class = forms.ManufacturerForm
success_url = 'dcim:manufacturer_list'
cancel_url = 'dcim:manufacturer_list'
def get_return_url(self, obj):
return reverse('dcim:manufacturer_list')
class ManufacturerBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -300,10 +361,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'))
)
@@ -332,13 +397,14 @@ class DeviceTypeEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_devicetype'
model = DeviceType
form_class = forms.DeviceTypeForm
cancel_url = 'dcim:devicetype_list'
template_name = 'dcim/devicetype_edit.html'
obj_list_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):
@@ -359,69 +425,30 @@ class DeviceTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Device type components
#
class ComponentTemplateCreateView(View):
model = None
form = None
def get(self, request, pk):
devicetype = get_object_or_404(DeviceType, pk=pk)
return render(request, 'dcim/component_template_add.html', {
'devicetype': devicetype,
'component_type': self.model._meta.verbose_name,
'form': self.form(initial=request.GET),
'cancel_url': reverse('dcim:devicetype', kwargs={'pk': devicetype.pk}),
})
def post(self, request, pk):
devicetype = get_object_or_404(DeviceType, pk=pk)
form = self.form(request.POST)
if form.is_valid():
component_templates = []
for name in form.cleaned_data['name_pattern']:
component_template = self.form(request.POST).save(commit=False)
component_template.device_type = devicetype
component_template.name = name
try:
component_template.full_clean()
component_templates.append(component_template)
except ValidationError:
form.add_error('name_pattern', "Duplicate name found: {}".format(name))
if not form.errors:
self.model.objects.bulk_create(component_templates)
messages.success(request, u"Added {} component(s) to {}.".format(len(component_templates), devicetype))
if '_addanother' in request.POST:
return redirect(request.path)
else:
return redirect('dcim:devicetype', pk=devicetype.pk)
return render(request, 'dcim/component_template_add.html', {
'devicetype': devicetype,
'component_type': self.model._meta.verbose_name,
'form': form,
'cancel_url': reverse('dcim:devicetype', kwargs={'pk': devicetype.pk}),
})
class ConsolePortTemplateAddView(ComponentTemplateCreateView):
class ConsolePortTemplateAddView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_consoleporttemplate'
parent_model = DeviceType
parent_field = 'device_type'
model = ConsolePortTemplate
form = forms.ConsolePortTemplateForm
form = forms.ConsolePortTemplateCreateForm
model_form = forms.ConsolePortTemplateForm
class ConsolePortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_consoleporttemplate'
parent_model = DeviceType
parent_field = 'device_type'
cls = ConsolePortTemplate
parent_cls = DeviceType
class ConsoleServerPortTemplateAddView(ComponentTemplateCreateView):
class ConsoleServerPortTemplateAddView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_consoleserverporttemplate'
parent_model = DeviceType
parent_field = 'device_type'
model = ConsoleServerPortTemplate
form = forms.ConsoleServerPortTemplateForm
form = forms.ConsoleServerPortTemplateCreateForm
model_form = forms.ConsoleServerPortTemplateForm
class ConsoleServerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -430,9 +457,13 @@ class ConsoleServerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDelet
parent_cls = DeviceType
class PowerPortTemplateAddView(ComponentTemplateCreateView):
class PowerPortTemplateAddView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_powerporttemplate'
parent_model = DeviceType
parent_field = 'device_type'
model = PowerPortTemplate
form = forms.PowerPortTemplateForm
form = forms.PowerPortTemplateCreateForm
model_form = forms.PowerPortTemplateForm
class PowerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -441,9 +472,13 @@ class PowerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
parent_cls = DeviceType
class PowerOutletTemplateAddView(ComponentTemplateCreateView):
class PowerOutletTemplateAddView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_poweroutlettemplate'
parent_model = DeviceType
parent_field = 'device_type'
model = PowerOutletTemplate
form = forms.PowerOutletTemplateForm
form = forms.PowerOutletTemplateCreateForm
model_form = forms.PowerOutletTemplateForm
class PowerOutletTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -452,9 +487,13 @@ class PowerOutletTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView)
parent_cls = DeviceType
class InterfaceTemplateAddView(ComponentTemplateCreateView):
class InterfaceTemplateAddView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_interfacetemplate'
parent_model = DeviceType
parent_field = 'device_type'
model = InterfaceTemplate
form = forms.InterfaceTemplateForm
form = forms.InterfaceTemplateCreateForm
model_form = forms.InterfaceTemplateForm
class InterfaceTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
@@ -471,9 +510,13 @@ class InterfaceTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
parent_cls = DeviceType
class DeviceBayTemplateAddView(ComponentTemplateCreateView):
class DeviceBayTemplateAddView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_devicebaytemplate'
parent_model = DeviceType
parent_field = 'device_type'
model = DeviceBayTemplate
form = forms.DeviceBayTemplateForm
form = forms.DeviceBayTemplateCreateForm
model_form = forms.DeviceBayTemplateForm
class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -497,8 +540,9 @@ class DeviceRoleEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_devicerole'
model = DeviceRole
form_class = forms.DeviceRoleForm
success_url = 'dcim:devicerole_list'
cancel_url = 'dcim:devicerole_list'
def get_return_url(self, obj):
return reverse('dcim:devicerole_list')
class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -522,8 +566,9 @@ class PlatformEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_platform'
model = Platform
form_class = forms.PlatformForm
success_url = 'dcim:platform_list'
cancel_url = 'dcim:platform_list'
def get_return_url(self, obj):
return reverse('dcim:platform_list')
class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -561,21 +606,24 @@ 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', 'connected_as_b', 'circuit')
mgmt_interfaces = Interface.objects.filter(device=device, mgmt_only=True)\
.select_related('connected_as_a', 'connected_as_b', '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')
)
# Gather any secrets which belong to this device
secrets = device.secrets.all()
# Find all IP addresses assigned to this device
# Gather relevant device objects
ip_addresses = IPAddress.objects.filter(interface__device=device).select_related('interface', 'vrf')\
.order_by('address')
services = Service.objects.filter(device=device)
secrets = device.secrets.all()
# Find any related devices for convenient linking in the UI
related_devices = []
@@ -605,6 +653,7 @@ def device(request, pk):
'mgmt_interfaces': mgmt_interfaces,
'device_bays': device_bays,
'ip_addresses': ip_addresses,
'services': services,
'secrets': secrets,
'related_devices': related_devices,
'show_graphs': show_graphs,
@@ -617,13 +666,13 @@ class DeviceEditView(PermissionRequiredMixin, ObjectEditView):
form_class = forms.DeviceForm
fields_initial = ['site', 'rack', 'position', 'face', 'device_bay']
template_name = 'dcim/device_edit.html'
cancel_url = 'dcim:device_list'
obj_list_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):
@@ -688,121 +737,17 @@ def device_lldp_neighbors(request, pk):
})
class DeviceBulkAddComponentView(View):
"""
Add one or more components (e.g. interfaces) to a selected set of Devices.
"""
form = None
component_cls = None
component_form = None
def get(self):
return redirect('dcim:device_list')
def post(self, request):
# Are we editing *all* objects in the queryset or just a selected subset?
if request.POST.get('_all'):
pk_list = [int(pk) for pk in request.POST.get('pk_all').split(',') if pk]
else:
pk_list = [int(pk) for pk in request.POST.getlist('pk')]
if '_create' in request.POST:
form = self.form(request.POST)
if form.is_valid():
new_components = []
data = deepcopy(form.cleaned_data)
for device in data['pk']:
names = data['name_pattern']
for name in names:
component_data = {
'device': device.pk,
'name': name,
}
component_data.update(data)
component_form = self.component_form(component_data)
if component_form.is_valid():
new_components.append(component_form.save(commit=False))
else:
form.add_error('name_pattern', "Duplicate {} name for {}: {}".format(
self.component_cls._meta.verbose_name, device, name
))
if not form.errors:
self.component_cls.objects.bulk_create(new_components)
messages.success(request, u"Added {} {} to {} devices.".format(
len(new_components), self.component_cls._meta.verbose_name_plural, len(form.cleaned_data['pk'])
))
return redirect('dcim:device_list')
else:
form = self.form(initial={'pk': pk_list})
selected_devices = Device.objects.filter(pk__in=pk_list)
if not selected_devices:
messages.warning(request, u"No devices were selected.")
return redirect('dcim:device_list')
return render(request, 'dcim/device_bulk_add_component.html', {
'form': form,
'component_name': self.component_cls._meta.verbose_name_plural,
'selected_devices': selected_devices,
'cancel_url': reverse('dcim:device_list'),
})
class DeviceBulkAddInterfaceView(DeviceBulkAddComponentView):
"""
Add one or more components (e.g. interfaces) to a selected set of Devices.
"""
form = forms.DeviceBulkAddInterfaceForm
component_cls = Interface
component_form = forms.InterfaceForm
#
# Console ports
#
@permission_required('dcim.add_consoleport')
def consoleport_add(request, pk):
device = get_object_or_404(Device, pk=pk)
if request.method == 'POST':
form = forms.ConsolePortCreateForm(request.POST)
if form.is_valid():
console_ports = []
for name in form.cleaned_data['name_pattern']:
cp_form = forms.ConsolePortForm({
'device': device.pk,
'name': name,
})
if cp_form.is_valid():
console_ports.append(cp_form.save(commit=False))
else:
form.add_error('name_pattern', "Duplicate console port name for this device: {}".format(name))
if not form.errors:
ConsolePort.objects.bulk_create(console_ports)
messages.success(request, u"Added {} console port(s) to {}.".format(len(console_ports), device))
if '_addanother' in request.POST:
return redirect('dcim:consoleport_add', pk=device.pk)
else:
return redirect('dcim:device', pk=device.pk)
else:
form = forms.ConsolePortCreateForm()
return render(request, 'dcim/device_component_add.html', {
'device': device,
'component_type': 'Console Port',
'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}),
})
class ConsolePortAddView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_consoleport'
parent_model = Device
parent_field = 'device'
model = ConsolePort
form = forms.ConsolePortCreateForm
model_form = forms.ConsolePortForm
@permission_required('dcim.change_consoleport')
@@ -892,44 +837,13 @@ class ConsoleConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView):
# Console server ports
#
@permission_required('dcim.add_consoleserverport')
def consoleserverport_add(request, pk):
device = get_object_or_404(Device, pk=pk)
if request.method == 'POST':
form = forms.ConsoleServerPortCreateForm(request.POST)
if form.is_valid():
cs_ports = []
for name in form.cleaned_data['name_pattern']:
csp_form = forms.ConsoleServerPortForm({
'device': device.pk,
'name': name,
})
if csp_form.is_valid():
cs_ports.append(csp_form.save(commit=False))
else:
form.add_error('name_pattern', "Duplicate console server port name for this device: {}"
.format(name))
if not form.errors:
ConsoleServerPort.objects.bulk_create(cs_ports)
messages.success(request, u"Added {} console server port(s) to {}.".format(len(cs_ports), device))
if '_addanother' in request.POST:
return redirect('dcim:consoleserverport_add', pk=device.pk)
else:
return redirect('dcim:device', pk=device.pk)
else:
form = forms.ConsoleServerPortCreateForm()
return render(request, 'dcim/device_component_add.html', {
'device': device,
'component_type': 'Console Server Port',
'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}),
})
class ConsoleServerPortAddView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_consoleserverport'
parent_model = Device
parent_field = 'device'
model = ConsoleServerPort
form = forms.ConsoleServerPortCreateForm
model_form = forms.ConsoleServerPortForm
@permission_required('dcim.change_consoleserverport')
@@ -1013,43 +927,13 @@ class ConsoleServerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Power ports
#
@permission_required('dcim.add_powerport')
def powerport_add(request, pk):
device = get_object_or_404(Device, pk=pk)
if request.method == 'POST':
form = forms.PowerPortCreateForm(request.POST)
if form.is_valid():
power_ports = []
for name in form.cleaned_data['name_pattern']:
pp_form = forms.PowerPortForm({
'device': device.pk,
'name': name,
})
if pp_form.is_valid():
power_ports.append(pp_form.save(commit=False))
else:
form.add_error('name_pattern', "Duplicate power port name for this device: {}".format(name))
if not form.errors:
PowerPort.objects.bulk_create(power_ports)
messages.success(request, u"Added {} power port(s) to {}.".format(len(power_ports), device))
if '_addanother' in request.POST:
return redirect('dcim:powerport_add', pk=device.pk)
else:
return redirect('dcim:device', pk=device.pk)
else:
form = forms.PowerPortCreateForm()
return render(request, 'dcim/device_component_add.html', {
'device': device,
'component_type': 'Power Port',
'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}),
})
class PowerPortAddView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_powerport'
parent_model = Device
parent_field = 'device'
model = PowerPort
form = forms.PowerPortCreateForm
model_form = forms.PowerPortForm
@permission_required('dcim.change_powerport')
@@ -1139,43 +1023,13 @@ class PowerConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView):
# Power outlets
#
@permission_required('dcim.add_poweroutlet')
def poweroutlet_add(request, pk):
device = get_object_or_404(Device, pk=pk)
if request.method == 'POST':
form = forms.PowerOutletCreateForm(request.POST)
if form.is_valid():
power_outlets = []
for name in form.cleaned_data['name_pattern']:
po_form = forms.PowerOutletForm({
'device': device.pk,
'name': name,
})
if po_form.is_valid():
power_outlets.append(po_form.save(commit=False))
else:
form.add_error('name_pattern', "Duplicate power outlet name for this device: {}".format(name))
if not form.errors:
PowerOutlet.objects.bulk_create(power_outlets)
messages.success(request, u"Added {} power outlet(s) to {}.".format(len(power_outlets), device))
if '_addanother' in request.POST:
return redirect('dcim:poweroutlet_add', pk=device.pk)
else:
return redirect('dcim:device', pk=device.pk)
else:
form = forms.PowerOutletCreateForm()
return render(request, 'dcim/device_component_add.html', {
'device': device,
'component_type': 'Power Outlet',
'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}),
})
class PowerOutletAddView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_poweroutlet'
parent_model = Device
parent_field = 'device'
model = PowerOutlet
form = forms.PowerOutletCreateForm
model_form = forms.PowerOutletForm
@permission_required('dcim.change_poweroutlet')
@@ -1258,47 +1112,13 @@ class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Interfaces
#
@permission_required('dcim.add_interface')
def interface_add(request, pk):
device = get_object_or_404(Device, pk=pk)
if request.method == 'POST':
form = forms.InterfaceCreateForm(request.POST)
if form.is_valid():
interfaces = []
for name in form.cleaned_data['name_pattern']:
iface_form = forms.InterfaceForm({
'device': device.pk,
'name': name,
'form_factor': form.cleaned_data['form_factor'],
'mac_address': form.cleaned_data['mac_address'],
'mgmt_only': form.cleaned_data['mgmt_only'],
'description': form.cleaned_data['description'],
})
if iface_form.is_valid():
interfaces.append(iface_form.save(commit=False))
else:
form.add_error('name_pattern', "Duplicate interface name for this device: {}".format(name))
if not form.errors:
Interface.objects.bulk_create(interfaces)
messages.success(request, u"Added {} interface(s) to {}.".format(len(interfaces), device))
if '_addanother' in request.POST:
return redirect('dcim:interface_add', pk=device.pk)
else:
return redirect('dcim:device', pk=device.pk)
else:
form = forms.InterfaceCreateForm(initial={'mgmt_only': request.GET.get('mgmt_only')})
return render(request, 'dcim/device_component_add.html', {
'device': device,
'component_type': 'Interface',
'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}),
})
class InterfaceAddView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_interface'
parent_model = Device
parent_field = 'device'
model = Interface
form = forms.InterfaceCreateForm
model_form = forms.InterfaceForm
class InterfaceEditView(PermissionRequiredMixin, ObjectEditView):
@@ -1330,44 +1150,13 @@ class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Device bays
#
@permission_required('dcim.add_devicebay')
def devicebay_add(request, pk):
device = get_object_or_404(Device, pk=pk)
if request.method == 'POST':
form = forms.DeviceBayCreateForm(request.POST)
if form.is_valid():
device_bays = []
for name in form.cleaned_data['name_pattern']:
devicebay_form = forms.DeviceBayForm({
'device': device.pk,
'name': name,
})
if devicebay_form.is_valid():
device_bays.append(devicebay_form.save(commit=False))
else:
for err in devicebay_form.errors.get('__all__', []):
form.add_error('name_pattern', err)
if not form.errors:
DeviceBay.objects.bulk_create(device_bays)
messages.success(request, u"Added {} device bay(s) to {}.".format(len(device_bays), device))
if '_addanother' in request.POST:
return redirect('dcim:devicebay_add', pk=device.pk)
else:
return redirect('dcim:device', pk=device.pk)
else:
form = forms.DeviceBayCreateForm()
return render(request, 'dcim/device_component_add.html', {
'device': device,
'component_type': 'Device Bay',
'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}),
})
class DeviceBayAddView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_devicebay'
parent_model = Device
parent_field = 'device'
model = DeviceBay
form = forms.DeviceBayCreateForm
model_form = forms.DeviceBayForm
class DeviceBayEditView(PermissionRequiredMixin, ObjectEditView):
@@ -1437,6 +1226,112 @@ class DeviceBayBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
parent_cls = Device
#
# Bulk device component creation
#
class DeviceBulkAddComponentView(View):
"""
Add one or more components (e.g. interfaces) to a selected set of Devices.
"""
form = forms.DeviceBulkAddComponentForm
model = None
model_form = None
def get(self):
return redirect('dcim:device_list')
def post(self, request):
# Are we editing *all* objects in the queryset or just a selected subset?
if request.POST.get('_all'):
pk_list = [int(pk) for pk in request.POST.get('pk_all').split(',') if pk]
else:
pk_list = [int(pk) for pk in request.POST.getlist('pk')]
if '_create' in request.POST:
form = self.form(request.POST)
if form.is_valid():
new_components = []
data = deepcopy(form.cleaned_data)
for device in data['pk']:
names = data['name_pattern']
for name in names:
component_data = {
'device': device.pk,
'name': name,
}
component_data.update(data)
component_form = self.model_form(component_data)
if component_form.is_valid():
new_components.append(component_form.save(commit=False))
else:
for field, errors in component_form.errors.as_data().items():
for e in errors:
form.add_error(field, u'{} {}: {}'.format(device, name, ', '.join(e)))
if not form.errors:
self.model.objects.bulk_create(new_components)
messages.success(request, u"Added {} {} to {} devices.".format(
len(new_components), self.model._meta.verbose_name_plural, len(form.cleaned_data['pk'])
))
return redirect('dcim:device_list')
else:
form = self.form(initial={'pk': pk_list})
selected_devices = Device.objects.filter(pk__in=pk_list)
if not selected_devices:
messages.warning(request, u"No devices were selected.")
return redirect('dcim:device_list')
return render(request, 'dcim/device_bulk_add_component.html', {
'form': form,
'component_name': self.model._meta.verbose_name_plural,
'selected_devices': selected_devices,
'cancel_url': reverse('dcim:device_list'),
})
class DeviceBulkAddConsolePortView(PermissionRequiredMixin, DeviceBulkAddComponentView):
permission_required = 'dcim.add_consoleport'
model = ConsolePort
model_form = forms.ConsolePortForm
class DeviceBulkAddConsoleServerPortView(PermissionRequiredMixin, DeviceBulkAddComponentView):
permission_required = 'dcim.add_consoleserverport'
model = ConsoleServerPort
model_form = forms.ConsoleServerPortForm
class DeviceBulkAddPowerPortView(PermissionRequiredMixin, DeviceBulkAddComponentView):
permission_required = 'dcim.add_powerport'
model = PowerPort
model_form = forms.PowerPortForm
class DeviceBulkAddPowerOutletView(PermissionRequiredMixin, DeviceBulkAddComponentView):
permission_required = 'dcim.add_poweroutlet'
model = PowerOutlet
model_form = forms.PowerOutletForm
class DeviceBulkAddInterfaceView(PermissionRequiredMixin, DeviceBulkAddComponentView):
permission_required = 'dcim.add_interface'
form = forms.DeviceBulkAddInterfaceForm
model = Interface
model_form = forms.InterfaceForm
class DeviceBulkAddDeviceBayView(PermissionRequiredMixin, DeviceBulkAddComponentView):
permission_required = 'dcim.add_devicebay'
model = DeviceBay
model_form = forms.DeviceBayForm
#
# Interface connections
#
@@ -1468,9 +1363,11 @@ def interfaceconnection_add(request, pk):
else:
form = forms.InterfaceConnectionForm(device, initial={
'interface_a': request.GET.get('interface', None),
'interface_a': request.GET.get('interface_a', None),
'site_b': request.GET.get('site_b', device.rack.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),
})
return render(request, 'dcim/interfaceconnection_edit.html', {
@@ -1603,39 +1500,19 @@ def ipaddress_assign(request, pk):
# Modules
#
@permission_required('dcim.add_module')
def module_add(request, pk):
device = get_object_or_404(Device, pk=pk)
if request.method == 'POST':
form = forms.ModuleForm(request.POST)
if form.is_valid():
module = form.save(commit=False)
module.device = device
module.save()
messages.success(request, u"Added module {} to {}".format(module.name, module.device.name))
if '_addanother' in request.POST:
return redirect('dcim:module_add', pk=module.device.pk)
else:
return redirect('dcim:device_inventory', pk=module.device.pk)
else:
form = forms.ModuleForm()
return render(request, 'dcim/device_component_add.html', {
'device': device,
'component_type': 'Module',
'form': form,
'cancel_url': reverse('dcim:device_inventory', kwargs={'pk': device.pk}),
})
class ModuleEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_module'
model = Module
form_class = forms.ModuleForm
def alter_obj(self, obj, args, kwargs):
if 'device' in kwargs:
obj.device = get_object_or_404(Device, pk=kwargs['device'])
return obj
def get_return_url(self, obj):
return obj.device.get_absolute_url()
class ModuleDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_module'

View File

@@ -1,5 +1,6 @@
from django import forms
from django.contrib import admin
from django.utils.safestring import mark_safe
from .models import CustomField, CustomFieldChoice, Graph, ExportTemplate, TopologyMap, UserAction
@@ -54,4 +55,7 @@ class TopologyMapAdmin(admin.ModelAdmin):
@admin.register(UserAction)
class UserActionAdmin(admin.ModelAdmin):
actions = None
list_display = ['user', 'action', 'content_type', 'object_id', 'message']
list_display = ['user', 'action', 'content_type', 'object_id', '_message']
def _message(self, obj):
return mark_safe(obj.message)

View File

@@ -34,9 +34,9 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
(0, 'False'),
)
if cf.default.lower() in ['true', 'yes', '1']:
initial = True
initial = 1
elif cf.default.lower() in ['false', 'no', '0']:
initial = False
initial = 0
else:
initial = None
field = forms.NullBooleanField(required=cf.required, initial=initial,
@@ -44,12 +44,12 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
# Date
elif cf.type == CF_TYPE_DATE:
field = forms.DateField(required=cf.required, initial=cf.default)
field = forms.DateField(required=cf.required, initial=cf.default, help_text="Date format: YYYY-MM-DD")
# Select
elif cf.type == CF_TYPE_SELECT:
choices = [(cfc.pk, cfc) for cfc in cf.choices.all()]
if bulk_edit or filterable_only:
if not cf.required or bulk_edit or filterable_only:
choices = [(None, '---------')] + choices
field = forms.TypedChoiceField(choices=choices, coerce=int, required=cf.required)
@@ -63,7 +63,8 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
field.model = cf
field.label = cf.label if cf.label else cf.name.replace('_', ' ').capitalize()
field.help_text = cf.description
if cf.description:
field.help_text = cf.description
field_dict[field_name] = field

View File

@@ -12,7 +12,7 @@ from django.utils.safestring import mark_safe
CUSTOMFIELD_MODELS = (
'site', 'rack', 'device', # DCIM
'site', 'rack', 'devicetype', 'device', # DCIM
'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', # IPAM
'provider', 'circuit', # Circuits
'tenant', # Tenants
@@ -130,7 +130,7 @@ class CustomField(models.Model):
if self.type == CF_TYPE_SELECT:
# Could be ModelChoiceField or TypedChoiceField
return str(value.id) if hasattr(value, 'id') else str(value)
return str(value)
return value
def deserialize_value(self, serialized_value):
"""
@@ -165,7 +165,7 @@ class CustomFieldValue(models.Model):
unique_together = ['field', 'obj_type', 'obj_id']
def __unicode__(self):
return '{} {}'.format(self.obj, self.field)
return u'{} {}'.format(self.obj, self.field)
@property
def value(self):

View File

@@ -28,7 +28,7 @@ class RIRAdmin(admin.ModelAdmin):
prepopulated_fields = {
'slug': ['name'],
}
list_display = ['name', 'slug']
list_display = ['name', 'slug', 'is_private']
@admin.register(Aggregate)

View File

@@ -1,8 +1,8 @@
from rest_framework import serializers
from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer
from dcim.api.serializers import DeviceNestedSerializer, InterfaceNestedSerializer, SiteNestedSerializer
from extras.api.serializers import CustomFieldSerializer
from ipam.models import VRF, Role, RIR, Aggregate, Prefix, IPAddress, VLAN, VLANGroup
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
from tenancy.api.serializers import TenantNestedSerializer
@@ -58,13 +58,13 @@ class RIRSerializer(serializers.ModelSerializer):
class Meta:
model = RIR
fields = ['id', 'name', 'slug']
fields = ['id', 'name', 'slug', 'is_private']
class RIRNestedSerializer(RIRSerializer):
class Meta(RIRSerializer.Meta):
pass
fields = ['id', 'name', 'slug']
#
@@ -138,7 +138,7 @@ class PrefixSerializer(CustomFieldSerializer, serializers.ModelSerializer):
class Meta:
model = Prefix
fields = ['id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'description',
fields = ['id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description',
'custom_fields']
@@ -170,3 +170,22 @@ class IPAddressNestedSerializer(IPAddressSerializer):
IPAddressSerializer._declared_fields['nat_inside'] = IPAddressNestedSerializer()
IPAddressSerializer._declared_fields['nat_outside'] = IPAddressNestedSerializer()
#
# Services
#
class ServiceSerializer(serializers.ModelSerializer):
device = DeviceNestedSerializer()
ipaddresses = IPAddressNestedSerializer(many=True)
class Meta:
model = Service
fields = ['id', 'device', 'name', 'port', 'protocol', 'ipaddresses', 'description']
class ServiceNestedSerializer(ServiceSerializer):
class Meta(ServiceSerializer.Meta):
fields = ['id', 'name', 'port', 'protocol']

View File

@@ -37,4 +37,8 @@ urlpatterns = [
url(r'^vlans/$', VLANListView.as_view(), name='vlan_list'),
url(r'^vlans/(?P<pk>\d+)/$', VLANDetailView.as_view(), name='vlan_detail'),
# Services
url(r'^services/$', ServiceListView.as_view(), name='service_list'),
url(r'^services/(?P<pk>\d+)/$', ServiceDetailView.as_view(), name='service_detail'),
]

View File

@@ -1,6 +1,6 @@
from rest_framework import generics
from ipam.models import VRF, Role, RIR, Aggregate, Prefix, IPAddress, VLAN, VLANGroup
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
from ipam import filters
from extras.api.views import CustomFieldModelAPIView
@@ -177,3 +177,24 @@ class VLANDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')\
.prefetch_related('custom_field_values__field')
serializer_class = serializers.VLANSerializer
#
# Services
#
class ServiceListView(generics.ListAPIView):
"""
List services (filterable)
"""
queryset = Service.objects.select_related('device').prefetch_related('ipaddresses')
serializer_class = serializers.ServiceSerializer
filter_class = filters.ServiceFilter
class ServiceDetailView(generics.RetrieveAPIView):
"""
Retrieve a single service
"""
queryset = Service.objects.select_related('device').prefetch_related('ipaddresses')
serializer_class = serializers.ServiceSerializer

View File

@@ -9,7 +9,7 @@ from extras.filters import CustomFieldFilterSet
from tenancy.models import Tenant
from utilities.filters import NullableModelMultipleChoiceFilter
from .models import RIR, Aggregate, VRF, Prefix, IPAddress, VLAN, VLANGroup, Role
from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet):
@@ -43,7 +43,14 @@ class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet):
class Meta:
model = VRF
fields = ['name', 'rd']
fields = ['rd']
class RIRFilter(django_filters.FilterSet):
class Meta:
model = RIR
fields = ['is_private']
class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet):
@@ -57,7 +64,7 @@ class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet):
label='RIR (ID)',
)
rir = django_filters.ModelMultipleChoiceFilter(
name='rir',
name='rir__slug',
queryset=RIR.objects.all(),
to_field_name='slug',
label='RIR (slug)',
@@ -65,7 +72,7 @@ class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet):
class Meta:
model = Aggregate
fields = ['family', 'rir_id', 'rir', 'date_added']
fields = ['family', 'date_added']
def search(self, queryset, value):
qs_filter = Q(description__icontains=value)
@@ -119,7 +126,7 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug',
label='Site (slug)',
)
vlan_id = django_filters.ModelMultipleChoiceFilter(
vlan_id = NullableModelMultipleChoiceFilter(
name='vlan',
queryset=VLAN.objects.all(),
label='VLAN (ID)',
@@ -142,7 +149,7 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
class Meta:
model = Prefix
fields = ['family', 'site_id', 'site', 'vlan_id', 'vlan_vid', 'status', 'role_id', 'role']
fields = ['family', 'status']
def search(self, queryset, value):
qs_filter = Q(description__icontains=value)
@@ -219,7 +226,7 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
label='Device (ID)',
)
device = django_filters.ModelMultipleChoiceFilter(
name='interface__device',
name='interface__device__name',
queryset=Device.objects.all(),
to_field_name='name',
label='Device (name)',
@@ -232,7 +239,7 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
class Meta:
model = IPAddress
fields = ['q', 'family', 'status', 'device_id', 'device', 'interface_id']
fields = ['q', 'family', 'status']
def search(self, queryset, value):
qs_filter = Q(description__icontains=value)
@@ -261,7 +268,7 @@ class VLANGroupFilter(django_filters.FilterSet):
label='Site (ID)',
)
site = django_filters.ModelMultipleChoiceFilter(
name='site',
name='site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site (slug)',
@@ -269,7 +276,6 @@ class VLANGroupFilter(django_filters.FilterSet):
class Meta:
model = VLANGroup
fields = ['site_id', 'site']
class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
@@ -283,7 +289,7 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
label='Site (ID)',
)
site = django_filters.ModelMultipleChoiceFilter(
name='site',
name='site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site (slug)',
@@ -333,7 +339,7 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
class Meta:
model = VLAN
fields = ['site_id', 'site', 'vid', 'name', 'status', 'role_id', 'role']
fields = ['status']
def search(self, queryset, value):
qs_filter = Q(name__icontains=value) | Q(description__icontains=value)
@@ -342,3 +348,21 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
except ValueError:
pass
return queryset.filter(qs_filter)
class ServiceFilter(django_filters.FilterSet):
device_id = django_filters.ModelMultipleChoiceFilter(
name='device',
queryset=Device.objects.all(),
label='Device (ID)',
)
device = django_filters.ModelMultipleChoiceFilter(
name='device__name',
queryset=Device.objects.all(),
to_field_name='name',
label='Device (name)',
)
class Meta:
model = Service
fields = ['name', 'protocol', 'port']

View File

@@ -43,7 +43,8 @@
"pk": 1,
"fields": {
"name": "ARIN",
"slug": "arin"
"slug": "arin",
"is_private": false
}
},
{
@@ -51,7 +52,8 @@
"pk": 2,
"fields": {
"name": "RIPE",
"slug": "ripe"
"slug": "ripe",
"is_private": false
}
},
{
@@ -59,7 +61,8 @@
"pk": 3,
"fields": {
"name": "APNIC",
"slug": "apnic"
"slug": "apnic",
"is_private": false
}
},
{
@@ -67,7 +70,8 @@
"pk": 4,
"fields": {
"name": "LACNIC",
"slug": "lacnic"
"slug": "lacnic",
"is_private": false
}
},
{
@@ -75,7 +79,8 @@
"pk": 5,
"fields": {
"name": "AFRINIC",
"slug": "afrinic"
"slug": "afrinic",
"is_private": false
}
},
{
@@ -83,7 +88,8 @@
"pk": 6,
"fields": {
"name": "RFC 1918",
"slug": "rfc-1918"
"slug": "rfc-1918",
"is_private": true
}
},
{

View File

@@ -5,12 +5,13 @@ from dcim.models import Site, Rack, Device, Interface
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
from tenancy.models import Tenant
from utilities.forms import (
APISelect, BootstrapMixin, CSVDataField, BulkImportForm, FilterChoiceField, Livesearch, SlugField, add_blank_choice,
APISelect, BootstrapMixin, BulkImportForm, CSVDataField, ExpandableIPAddressField, FilterChoiceField, Livesearch,
SlugField, add_blank_choice,
)
from .models import (
Aggregate, IPAddress, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, VLAN, VLANGroup,
VLAN_STATUS_CHOICES, VRF,
Aggregate, IPAddress, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, Service, VLAN,
VLANGroup, VLAN_STATUS_CHOICES, VRF,
)
@@ -47,7 +48,7 @@ class VRFFromCSVForm(forms.ModelForm):
fields = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
class VRFImportForm(BulkImportForm, BootstrapMixin):
class VRFImportForm(BootstrapMixin, BulkImportForm):
csv = CSVDataField(csv_form=VRFFromCSVForm)
@@ -70,12 +71,20 @@ class VRFFilterForm(BootstrapMixin, CustomFieldFilterForm):
# RIRs
#
class RIRForm(forms.ModelForm, BootstrapMixin):
class RIRForm(BootstrapMixin, forms.ModelForm):
slug = SlugField()
class Meta:
model = RIR
fields = ['name', 'slug']
fields = ['name', 'slug', 'is_private']
class RIRFilterForm(BootstrapMixin, forms.Form):
is_private = forms.NullBooleanField(required=False, label='Private', widget=forms.Select(choices=[
('', '---------'),
('True', 'Yes'),
('False', 'No'),
]))
#
@@ -103,7 +112,7 @@ class AggregateFromCSVForm(forms.ModelForm):
fields = ['prefix', 'rir', 'date_added', 'description']
class AggregateImportForm(BulkImportForm, BootstrapMixin):
class AggregateImportForm(BootstrapMixin, BulkImportForm):
csv = CSVDataField(csv_form=AggregateFromCSVForm)
@@ -128,7 +137,7 @@ class AggregateFilterForm(BootstrapMixin, CustomFieldFilterForm):
# Roles
#
class RoleForm(forms.ModelForm, BootstrapMixin):
class RoleForm(BootstrapMixin, forms.ModelForm):
slug = SlugField()
class Meta:
@@ -149,15 +158,7 @@ class PrefixForm(BootstrapMixin, CustomFieldForm):
class Meta:
model = Prefix
fields = ['prefix', 'vrf', 'tenant', 'site', 'vlan', 'status', 'role', 'description']
help_texts = {
'prefix': "IPv4 or IPv6 network",
'vrf': "VRF (if applicable)",
'site': "The site to which this prefix is assigned (if applicable)",
'vlan': "The VLAN to which this prefix is assigned (if applicable)",
'status': "Operational status of this prefix",
'role': "The primary function of this prefix",
}
fields = ['prefix', 'vrf', 'tenant', 'site', 'vlan', 'status', 'role', 'is_pool', 'description']
def __init__(self, *args, **kwargs):
super(PrefixForm, self).__init__(*args, **kwargs)
@@ -188,7 +189,7 @@ class PrefixFromCSVForm(forms.ModelForm):
class Meta:
model = Prefix
fields = ['prefix', 'vrf', 'tenant', 'site', 'vlan_group_name', 'vlan_vid', 'status_name', 'role',
fields = ['prefix', 'vrf', 'tenant', 'site', 'vlan_group_name', 'vlan_vid', 'status_name', 'role', 'is_pool',
'description']
def clean(self):
@@ -214,21 +215,22 @@ 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:
self.add_error('vlan_vid', "Must specify site and/or VLAN group when assigning a VLAN.")
def save(self, *args, **kwargs):
m = super(PrefixFromCSVForm, self).save(commit=False)
# Assign Prefix status by name
m.status = dict(self.fields['status_name'].choices)[self.cleaned_data['status_name']]
if kwargs.get('commit'):
m.save()
return m
self.instance.status = dict(self.fields['status_name'].choices)[self.cleaned_data['status_name']]
return super(PrefixFromCSVForm, self).save(*args, **kwargs)
class PrefixImportForm(BulkImportForm, BootstrapMixin):
class PrefixImportForm(BootstrapMixin, BulkImportForm):
csv = CSVDataField(csv_form=PrefixFromCSVForm)
@@ -332,13 +334,23 @@ class IPAddressForm(BootstrapMixin, CustomFieldForm):
self.fields['nat_inside'].choices = []
class IPAddressBulkAddForm(BootstrapMixin, forms.Form):
address = ExpandableIPAddressField()
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF', empty_label='Global')
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
status = forms.ChoiceField(choices=IPADDRESS_STATUS_CHOICES)
description = forms.CharField(max_length=100, required=False)
class IPAddressAssignForm(BootstrapMixin, forms.Form):
site = forms.ModelChoiceField(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'}))
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'}))
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')
)
@@ -391,7 +403,10 @@ class IPAddressFromCSVForm(forms.ModelForm):
if is_primary and not device:
self.add_error('is_primary', "No device specified; cannot set as primary IP")
def save(self, commit=True):
def save(self, *args, **kwargs):
# Assign status by name
self.instance.status = dict(self.fields['status_name'].choices)[self.cleaned_data['status_name']]
# Set interface
if self.cleaned_data['device'] and self.cleaned_data['interface_name']:
@@ -404,10 +419,10 @@ class IPAddressFromCSVForm(forms.ModelForm):
elif self.instance.address.version == 6:
self.instance.primary_ip6_for = self.cleaned_data['device']
return super(IPAddressFromCSVForm, self).save(commit=commit)
return super(IPAddressFromCSVForm, self).save(*args, **kwargs)
class IPAddressImportForm(BulkImportForm, BootstrapMixin):
class IPAddressImportForm(BootstrapMixin, BulkImportForm):
csv = CSVDataField(csv_form=IPAddressFromCSVForm)
@@ -446,7 +461,7 @@ class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
# VLAN groups
#
class VLANGroupForm(forms.ModelForm, BootstrapMixin):
class VLANGroupForm(BootstrapMixin, forms.ModelForm):
slug = SlugField()
class Meta:
@@ -454,7 +469,7 @@ class VLANGroupForm(forms.ModelForm, BootstrapMixin):
fields = ['site', 'name', 'slug']
class VLANGroupFilterForm(forms.Form, BootstrapMixin):
class VLANGroupFilterForm(BootstrapMixin, forms.Form):
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('vlan_groups')), to_field_name='slug')
@@ -519,7 +534,7 @@ class VLANFromCSVForm(forms.ModelForm):
return m
class VLANImportForm(BulkImportForm, BootstrapMixin):
class VLANImportForm(BootstrapMixin, BulkImportForm):
csv = CSVDataField(csv_form=VLANFromCSVForm)
@@ -553,3 +568,25 @@ class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm):
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'))
#
# Services
#
class ServiceForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = Service
fields = ['name', 'protocol', 'port', 'ipaddresses', 'description']
help_texts = {
'ipaddresses': "IP address assignment is optional. If no IPs are selected, the service is assumed to be "
"reachable via all IPs assigned to the device.",
}
def __init__(self, *args, **kwargs):
super(ServiceForm, self).__init__(*args, **kwargs)
# Limit IP address choices to those assigned to interfaces of the parent device
self.fields['ipaddresses'].queryset = IPAddress.objects.filter(interface__device=self.instance.device)

View File

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10 on 2016-12-06 18:27
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ipam', '0010_ipaddress_help_texts'),
]
operations = [
migrations.AddField(
model_name='rir',
name='is_private',
field=models.BooleanField(default=False, help_text=b'IP space managed by this RIR is considered private', verbose_name=b'Private'),
),
]

View File

@@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10 on 2016-12-15 20:22
from __future__ import unicode_literals
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('dcim', '0022_color_names_to_rgb'),
('ipam', '0011_rir_add_is_private'),
]
operations = [
migrations.CreateModel(
name='Service',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateField(auto_now_add=True)),
('last_updated', models.DateTimeField(auto_now=True)),
('name', models.CharField(max_length=30)),
('protocol', models.PositiveSmallIntegerField(choices=[(6, b'TCP'), (17, b'UDP')])),
('port', models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(65535)], verbose_name=b'Port number')),
('description', models.CharField(blank=True, max_length=100)),
('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='services', to='dcim.Device', verbose_name=b'device')),
('ipaddresses', models.ManyToManyField(blank=True, related_name='services', to='ipam.IPAddress', verbose_name=b'IP addresses')),
],
options={
'ordering': ['device', 'protocol', 'port'],
},
),
migrations.AlterUniqueTogether(
name='service',
unique_together=set([('device', 'protocol', 'port')]),
),
]

View File

@@ -0,0 +1,37 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2016-12-27 19:34
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
import ipam.fields
class Migration(migrations.Migration):
dependencies = [
('ipam', '0012_services'),
]
operations = [
migrations.AddField(
model_name='prefix',
name='is_pool',
field=models.BooleanField(default=False, help_text=b'All IP addresses within this prefix are considered usable', verbose_name=b'Is a pool'),
),
migrations.AlterField(
model_name='prefix',
name='prefix',
field=ipam.fields.IPNetworkField(help_text=b'IPv4 or IPv6 network with mask'),
),
migrations.AlterField(
model_name='prefix',
name='role',
field=models.ForeignKey(blank=True, help_text=b'The primary function of this prefix', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='prefixes', to='ipam.Role'),
),
migrations.AlterField(
model_name='prefix',
name='status',
field=models.PositiveSmallIntegerField(choices=[(0, b'Container'), (1, b'Active'), (2, b'Reserved'), (3, b'Deprecated')], default=1, help_text=b'Operational status of this prefix', verbose_name=b'Status'),
),
]

View File

@@ -13,6 +13,7 @@ from extras.models import CustomFieldModel, CustomFieldValue
from tenancy.models import Tenant
from utilities.models import CreatedUpdatedModel
from utilities.sql import NullsFirstQuerySet
from utilities.utils import csv_format
from .fields import IPNetworkField, IPAddressField
@@ -22,23 +23,33 @@ AF_CHOICES = (
(6, 'IPv6'),
)
PREFIX_STATUS_CONTAINER = 0
PREFIX_STATUS_ACTIVE = 1
PREFIX_STATUS_RESERVED = 2
PREFIX_STATUS_DEPRECATED = 3
PREFIX_STATUS_CHOICES = (
(0, 'Container'),
(1, 'Active'),
(2, 'Reserved'),
(3, 'Deprecated')
(PREFIX_STATUS_CONTAINER, 'Container'),
(PREFIX_STATUS_ACTIVE, 'Active'),
(PREFIX_STATUS_RESERVED, 'Reserved'),
(PREFIX_STATUS_DEPRECATED, 'Deprecated')
)
IPADDRESS_STATUS_ACTIVE = 1
IPADDRESS_STATUS_RESERVED = 2
IPADDRESS_STATUS_DHCP = 5
IPADDRESS_STATUS_CHOICES = (
(1, 'Active'),
(2, 'Reserved'),
(5, 'DHCP')
(IPADDRESS_STATUS_ACTIVE, 'Active'),
(IPADDRESS_STATUS_RESERVED, 'Reserved'),
(IPADDRESS_STATUS_DHCP, 'DHCP')
)
VLAN_STATUS_ACTIVE = 1
VLAN_STATUS_RESERVED = 2
VLAN_STATUS_DEPRECATED = 3
VLAN_STATUS_CHOICES = (
(1, 'Active'),
(2, 'Reserved'),
(3, 'Deprecated')
(VLAN_STATUS_ACTIVE, 'Active'),
(VLAN_STATUS_RESERVED, 'Reserved'),
(VLAN_STATUS_DEPRECATED, 'Deprecated')
)
STATUS_CHOICE_CLASSES = {
@@ -51,6 +62,14 @@ STATUS_CHOICE_CLASSES = {
}
IP_PROTOCOL_TCP = 6
IP_PROTOCOL_UDP = 17
IP_PROTOCOL_CHOICES = (
(IP_PROTOCOL_TCP, 'TCP'),
(IP_PROTOCOL_UDP, 'UDP'),
)
class VRF(CreatedUpdatedModel, CustomFieldModel):
"""
A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing
@@ -77,11 +96,11 @@ class VRF(CreatedUpdatedModel, CustomFieldModel):
return reverse('ipam:vrf', args=[self.pk])
def to_csv(self):
return ','.join([
return csv_format([
self.name,
self.rd,
self.tenant.name if self.tenant else '',
'True' if self.enforce_unique else '',
self.tenant.name if self.tenant else None,
self.enforce_unique,
self.description,
])
@@ -93,6 +112,8 @@ class RIR(models.Model):
"""
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
is_private = models.BooleanField(default=False, verbose_name='Private',
help_text='IP space managed by this RIR is considered private')
class Meta:
ordering = ['name']
@@ -163,10 +184,10 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel):
super(Aggregate, self).save(*args, **kwargs)
def to_csv(self):
return ','.join([
str(self.prefix),
return csv_format([
self.prefix,
self.rir.name,
self.date_added.isoformat() if self.date_added else '',
self.date_added.isoformat() if self.date_added else None,
self.description,
])
@@ -249,15 +270,19 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
assigned to a VLAN where appropriate.
"""
family = models.PositiveSmallIntegerField(choices=AF_CHOICES, editable=False)
prefix = IPNetworkField()
prefix = IPNetworkField(help_text="IPv4 or IPv6 network with mask")
site = models.ForeignKey('dcim.Site', related_name='prefixes', on_delete=models.PROTECT, blank=True, null=True)
vrf = models.ForeignKey('VRF', related_name='prefixes', on_delete=models.PROTECT, blank=True, null=True,
verbose_name='VRF')
tenant = models.ForeignKey(Tenant, related_name='prefixes', blank=True, null=True, on_delete=models.PROTECT)
vlan = models.ForeignKey('VLAN', related_name='prefixes', on_delete=models.PROTECT, blank=True, null=True,
verbose_name='VLAN')
status = models.PositiveSmallIntegerField('Status', choices=PREFIX_STATUS_CHOICES, default=1)
role = models.ForeignKey('Role', related_name='prefixes', on_delete=models.SET_NULL, blank=True, null=True)
status = models.PositiveSmallIntegerField('Status', choices=PREFIX_STATUS_CHOICES, default=PREFIX_STATUS_ACTIVE,
help_text="Operational status of this prefix")
role = models.ForeignKey('Role', related_name='prefixes', on_delete=models.SET_NULL, blank=True, null=True,
help_text="The primary function of this prefix")
is_pool = models.BooleanField(verbose_name='Is a pool', default=False,
help_text="All IP addresses within this prefix are considered usable")
description = models.CharField(max_length=100, blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
@@ -273,10 +298,14 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
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."
@@ -286,6 +315,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
@@ -295,13 +335,16 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
super(Prefix, self).save(*args, **kwargs)
def to_csv(self):
return ','.join([
str(self.prefix),
self.vrf.rd if self.vrf else '',
self.tenant.name if self.tenant else '',
self.site.name if self.site else '',
return csv_format([
self.prefix,
self.vrf.rd if self.vrf else None,
self.tenant.name if self.tenant else None,
self.site.name if self.site else None,
self.vlan.group.name if self.vlan and self.vlan.group else None,
self.vlan.vid if self.vlan else None,
self.get_status_display(),
self.role.name if self.role else '',
self.role.name if self.role else None,
self.is_pool,
self.description,
])
@@ -372,23 +415,23 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
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:
@@ -405,14 +448,14 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
elif self.family == 6 and getattr(self, 'primary_ip6_for', False):
is_primary = True
return ','.join([
str(self.address),
self.vrf.rd if self.vrf else '',
self.tenant.name if self.tenant else '',
return csv_format([
self.address,
self.vrf.rd if self.vrf else None,
self.tenant.name if self.tenant else None,
self.get_status_display(),
self.device.identifier if self.device else '',
self.interface.name if self.interface else '',
'True' if is_primary else '',
self.device.identifier if self.device else None,
self.interface.name if self.interface else None,
is_primary,
self.description,
])
@@ -496,14 +539,14 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
})
def to_csv(self):
return ','.join([
return csv_format([
self.site.name,
self.group.name if self.group else '',
str(self.vid),
self.group.name if self.group else None,
self.vid,
self.name,
self.tenant.name if self.tenant else '',
self.tenant.name if self.tenant else None,
self.get_status_display(),
self.role.name if self.role else '',
self.role.name if self.role else None,
self.description,
])
@@ -513,3 +556,25 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
def get_status_class(self):
return STATUS_CHOICE_CLASSES[self.status]
class Service(CreatedUpdatedModel):
"""
A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device. A Service may optionally be tied
to one or more specific IPAddresses belonging to the Device.
"""
device = models.ForeignKey('dcim.Device', related_name='services', on_delete=models.CASCADE, verbose_name='device')
name = models.CharField(max_length=30)
protocol = models.PositiveSmallIntegerField(choices=IP_PROTOCOL_CHOICES)
port = models.PositiveIntegerField(validators=[MinValueValidator(1), MaxValueValidator(65535)],
verbose_name='Port number')
ipaddresses = models.ManyToManyField('ipam.IPAddress', related_name='services', blank=True,
verbose_name='IP addresses')
description = models.CharField(max_length=100, blank=True)
class Meta:
ordering = ['device', 'protocol', 'port']
unique_together = ['device', 'protocol', 'port']
def __unicode__(self):
return u'{} ({}/{})'.format(self.name, self.port, self.get_protocol_display())

View File

@@ -6,6 +6,25 @@ from utilities.tables import BaseTable, ToggleColumn
from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF
RIR_UTILIZATION = """
<div class="progress">
{% if record.stats.total %}
<div class="progress-bar" role="progressbar" style="width: {{ record.stats.percentages.active }}%;">
<span class="sr-only">{{ record.stats.percentages.active }}%</span>
</div>
<div class="progress-bar progress-bar-info" role="progressbar" style="width: {{ record.stats.percentages.reserved }}%;">
<span class="sr-only">{{ record.stats.percentages.reserved }}%</span>
</div>
<div class="progress-bar progress-bar-danger" role="progressbar" style="width: {{ record.stats.percentages.deprecated }}%;">
<span class="sr-only">{{ record.stats.percentages.deprecated }}%</span>
</div>
<div class="progress-bar progress-bar-success" role="progressbar" style="width: {{ record.stats.percentages.available }}%;">
<span class="sr-only">{{ record.stats.percentages.available }}%</span>
</div>
{% endif %}
</div>
"""
RIR_ACTIONS = """
{% if perms.ipam.change_rir %}
<a href="{% url 'ipam:rir_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
@@ -39,6 +58,14 @@ PREFIX_LINK_BRIEF = """
</span>
"""
PREFIX_ROLE_LINK = """
{% if record.role %}
<a href="{% url 'ipam:prefix_list' %}?role={{ record.role.slug }}">{{ record.role }}</a>
{% else %}
&mdash;
{% endif %}
"""
IPADDRESS_LINK = """
{% if record.pk %}
<a href="{{ record.get_absolute_url }}">{{ record.address }}</a>
@@ -67,6 +94,22 @@ STATUS_LABEL = """
{% endif %}
"""
VLAN_PREFIXES = """
{% for prefix in record.prefixes.all %}
<a href="{% url 'ipam:prefix' pk=prefix.pk %}">{{ prefix }}</a>{% if not forloop.last %}<br />{% endif %}
{% empty %}
&mdash;
{% endfor %}
"""
VLAN_ROLE_LINK = """
{% if record.role %}
<a href="{% url 'ipam:vlan_list' %}?role={{ record.role.slug }}">{{ record.role }}</a>
{% else %}
&mdash;
{% endif %}
"""
VLANGROUP_ACTIONS = """
{% if perms.ipam.change_vlangroup %}
<a href="{% url 'ipam:vlangroup_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
@@ -93,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
@@ -107,13 +150,25 @@ class VRFTable(BaseTable):
class RIRTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn(verbose_name='Name')
is_private = tables.BooleanColumn(verbose_name='Private')
aggregate_count = tables.Column(verbose_name='Aggregates')
slug = tables.Column(verbose_name='Slug')
stats_total = tables.Column(accessor='stats.total', verbose_name='Total',
footer=lambda table: sum(r.stats['total'] for r in table.data))
stats_active = tables.Column(accessor='stats.active', verbose_name='Active',
footer=lambda table: sum(r.stats['active'] for r in table.data))
stats_reserved = tables.Column(accessor='stats.reserved', verbose_name='Reserved',
footer=lambda table: sum(r.stats['reserved'] for r in table.data))
stats_deprecated = tables.Column(accessor='stats.deprecated', verbose_name='Deprecated',
footer=lambda table: sum(r.stats['deprecated'] for r in table.data))
stats_available = tables.Column(accessor='stats.available', verbose_name='Available',
footer=lambda table: sum(r.stats['available'] for r in table.data))
utilization = tables.TemplateColumn(template_code=RIR_UTILIZATION, verbose_name='Utilization')
actions = tables.TemplateColumn(template_code=RIR_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name='')
class Meta(BaseTable.Meta):
model = RIR
fields = ('pk', 'name', 'aggregate_count', 'slug', 'actions')
fields = ('pk', 'name', 'is_private', 'aggregate_count', 'stats_total', 'stats_active', 'stats_reserved',
'stats_deprecated', 'stats_available', 'utilization', 'actions')
#
@@ -127,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
@@ -158,16 +213,17 @@ class RoleTable(BaseTable):
class PrefixTable(BaseTable):
pk = ToggleColumn()
status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
prefix = tables.TemplateColumn(PREFIX_LINK, verbose_name='Prefix')
prefix = tables.TemplateColumn(PREFIX_LINK, verbose_name='Prefix', attrs={'th': {'style': 'padding-left: 17px'}})
vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
tenant = tables.TemplateColumn(TENANT_LINK, verbose_name='Tenant')
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
role = tables.Column(verbose_name='Role')
description = tables.Column(orderable=False, verbose_name='Description')
vlan = tables.LinkColumn('ipam:vlan', args=[Accessor('vlan.pk')], verbose_name='VLAN')
role = tables.TemplateColumn(PREFIX_ROLE_LINK, verbose_name='Role')
description = tables.Column(verbose_name='Description')
class Meta(BaseTable.Meta):
model = Prefix
fields = ('pk', 'prefix', 'status', 'vrf', 'tenant', 'site', 'role', 'description')
fields = ('pk', 'prefix', 'status', 'vrf', 'tenant', 'site', 'vlan', 'role', 'description')
row_attrs = {
'class': lambda record: 'success' if not record.pk else '',
}
@@ -199,7 +255,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
@@ -250,10 +306,12 @@ class VLANTable(BaseTable):
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
name = tables.Column(verbose_name='Name')
prefixes = tables.TemplateColumn(VLAN_PREFIXES, orderable=False, verbose_name='Prefixes')
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
role = tables.Column(verbose_name='Role')
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', '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

@@ -51,6 +51,7 @@ urlpatterns = [
# IP addresses
url(r'^ip-addresses/$', views.IPAddressListView.as_view(), name='ipaddress_list'),
url(r'^ip-addresses/add/$', views.IPAddressEditView.as_view(), name='ipaddress_add'),
url(r'^ip-addresses/bulk-add/$', views.IPAddressBulkAddView.as_view(), name='ipaddress_bulk_add'),
url(r'^ip-addresses/import/$', views.IPAddressBulkImportView.as_view(), name='ipaddress_import'),
url(r'^ip-addresses/edit/$', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'),
url(r'^ip-addresses/delete/$', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'),
@@ -76,4 +77,8 @@ urlpatterns = [
url(r'^vlans/(?P<pk>\d+)/edit/$', views.VLANEditView.as_view(), name='vlan_edit'),
url(r'^vlans/(?P<pk>\d+)/delete/$', views.VLANDeleteView.as_view(), name='vlan_delete'),
# Services
url(r'^services/(?P<pk>\d+)/edit/$', views.ServiceEditView.as_view(), name='service_edit'),
url(r'^services/(?P<pk>\d+)/delete/$', views.ServiceDeleteView.as_view(), name='service_delete'),
]

View File

@@ -1,5 +1,5 @@
import netaddr
from django_tables2 import RequestConfig
import netaddr
from django.contrib.auth.decorators import permission_required
from django.contrib.auth.mixins import PermissionRequiredMixin
@@ -12,11 +12,14 @@ from dcim.models import Device
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator
from utilities.views import (
BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
BulkAddView, BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
)
from . import filters, forms, tables
from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF
from .models import (
Aggregate, IPAddress, PREFIX_STATUS_ACTIVE, PREFIX_STATUS_DEPRECATED, PREFIX_STATUS_RESERVED, Prefix, RIR, Role,
Service, VLAN, VLANGroup, VRF,
)
def add_available_prefixes(parent, prefix_list):
@@ -35,24 +38,21 @@ def add_available_prefixes(parent, prefix_list):
return prefix_list
def add_available_ipaddresses(prefix, ipaddress_list):
def add_available_ipaddresses(prefix, ipaddress_list, is_pool=False):
"""
Annotate ranges of available IP addresses within a given prefix.
Annotate ranges of available IP addresses within a given prefix. If is_pool is True, the first and last IP will be
considered usable (regardless of mask length).
"""
output = []
prev_ip = None
# Ignore the "network address" for IPv4 prefixes larger than /31
if prefix.version == 4 and prefix.prefixlen < 31:
# Ignore the network and broadcast addresses for non-pool IPv4 prefixes larger than /31.
if prefix.version == 4 and prefix.prefixlen < 31 and not is_pool:
first_ip_in_prefix = netaddr.IPAddress(prefix.first + 1)
else:
first_ip_in_prefix = netaddr.IPAddress(prefix.first)
# Ignore the broadcast address for IPv4 prefixes larger than /31
if prefix.version == 4 and prefix.prefixlen < 31:
last_ip_in_prefix = netaddr.IPAddress(prefix.last - 1)
else:
first_ip_in_prefix = netaddr.IPAddress(prefix.first)
last_ip_in_prefix = netaddr.IPAddress(prefix.last)
if not ipaddress_list:
@@ -102,8 +102,10 @@ class VRFListView(ObjectListView):
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 +118,13 @@ class VRFEditView(PermissionRequiredMixin, ObjectEditView):
model = VRF
form_class = forms.VRFForm
template_name = 'ipam/vrf_edit.html'
cancel_url = 'ipam:vrf_list'
obj_list_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):
@@ -153,17 +155,96 @@ class VRFBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class RIRListView(ObjectListView):
queryset = RIR.objects.annotate(aggregate_count=Count('aggregates'))
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):
if request.GET.get('family') == '6':
family = 6
denominator = 2 ** 64 # Count /64s for IPv6 rather than individual IPs
else:
family = 4
denominator = 1
rirs = []
for rir in self.queryset:
stats = {
'total': 0,
'active': 0,
'reserved': 0,
'deprecated': 0,
'available': 0,
}
aggregate_list = Aggregate.objects.filter(family=family, rir=rir)
for aggregate in aggregate_list:
queryset = Prefix.objects.filter(prefix__net_contained_or_equal=str(aggregate.prefix))
# Find all consumed space for each prefix status (we ignore containers for this purpose).
active_prefixes = netaddr.cidr_merge([p.prefix for p in queryset.filter(status=PREFIX_STATUS_ACTIVE)])
reserved_prefixes = netaddr.cidr_merge([p.prefix for p in queryset.filter(status=PREFIX_STATUS_RESERVED)])
deprecated_prefixes = netaddr.cidr_merge([p.prefix for p in queryset.filter(status=PREFIX_STATUS_DEPRECATED)])
# Find all available prefixes by subtracting each of the existing prefix sets from the aggregate prefix.
available_prefixes = (
netaddr.IPSet([aggregate.prefix]) -
netaddr.IPSet(active_prefixes) -
netaddr.IPSet(reserved_prefixes) -
netaddr.IPSet(deprecated_prefixes)
)
# Add the size of each metric to the RIR total.
stats['total'] += aggregate.prefix.size / denominator
stats['active'] += netaddr.IPSet(active_prefixes).size / denominator
stats['reserved'] += netaddr.IPSet(reserved_prefixes).size / denominator
stats['deprecated'] += netaddr.IPSet(deprecated_prefixes).size / denominator
stats['available'] += available_prefixes.size / denominator
# Calculate the percentage of total space for each prefix status.
total = float(stats['total'])
stats['percentages'] = {
'active': float('{:.2f}'.format(stats['active'] / total * 100)) if total else 0,
'reserved': float('{:.2f}'.format(stats['reserved'] / total * 100)) if total else 0,
'deprecated': float('{:.2f}'.format(stats['deprecated'] / total * 100)) if total else 0,
}
stats['percentages']['available'] = (
100 -
stats['percentages']['active'] -
stats['percentages']['reserved'] -
stats['percentages']['deprecated']
)
rir.stats = stats
rirs.append(rir)
return rirs
def extra_context(self):
totals = {
'total': sum([rir.stats['total'] for rir in self.queryset]),
'active': sum([rir.stats['active'] for rir in self.queryset]),
'reserved': sum([rir.stats['reserved'] for rir in self.queryset]),
'deprecated': sum([rir.stats['deprecated'] for rir in self.queryset]),
'available': sum([rir.stats['available'] for rir in self.queryset]),
}
return {
'totals': totals,
}
class RIREditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.change_rir'
model = RIR
form_class = forms.RIRForm
success_url = 'ipam:rir_list'
cancel_url = 'ipam:rir_list'
def get_return_url(self, obj):
return reverse('ipam:rir_list')
class RIRBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -212,7 +293,6 @@ def aggregate(request, pk):
child_prefixes = add_available_prefixes(aggregate.prefix, child_prefixes)
prefix_table = tables.PrefixTable(child_prefixes)
prefix_table.model = Prefix
if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
prefix_table.base_columns['pk'].visible = True
RequestConfig(request, paginate={'klass': EnhancedPaginator}).configure(prefix_table)
@@ -228,13 +308,13 @@ class AggregateEditView(PermissionRequiredMixin, ObjectEditView):
model = Aggregate
form_class = forms.AggregateForm
template_name = 'ipam/aggregate_edit.html'
cancel_url = 'ipam:aggregate_list'
obj_list_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):
@@ -274,8 +354,9 @@ class RoleEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.change_role'
model = Role
form_class = forms.RoleForm
success_url = 'ipam:role_list'
cancel_url = 'ipam:role_list'
def get_return_url(self, obj):
return reverse('ipam:role_list')
class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -289,7 +370,7 @@ class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
#
class PrefixListView(ObjectListView):
queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'role')
queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
filter = filters.PrefixFilter
filter_form = forms.PrefixFilterForm
table = tables.PrefixTable
@@ -324,7 +405,7 @@ def prefix(request, pk):
# 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))
# Child prefixes table
if prefix.vrf:
@@ -338,7 +419,6 @@ def prefix(request, pk):
if child_prefixes:
child_prefixes = add_available_prefixes(prefix.prefix, child_prefixes)
child_prefix_table = tables.PrefixTable(child_prefixes)
child_prefix_table.model = Prefix
if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
child_prefix_table.base_columns['pk'].visible = True
RequestConfig(request, paginate={'klass': EnhancedPaginator}).configure(child_prefix_table)
@@ -359,13 +439,14 @@ class PrefixEditView(PermissionRequiredMixin, ObjectEditView):
form_class = forms.PrefixForm
template_name = 'ipam/prefix_edit.html'
fields_initial = ['vrf', 'tenant', 'site', 'prefix', 'vlan']
cancel_url = 'ipam:prefix_list'
obj_list_url = 'ipam:prefix_list'
class PrefixDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_prefix'
model = Prefix
redirect_url = 'ipam:prefix_list'
default_return_url = 'ipam:prefix_list'
template_name = 'ipam/prefix_delete.html'
class PrefixBulkImportView(PermissionRequiredMixin, BulkImportView):
@@ -397,10 +478,9 @@ def prefix_ipaddresses(request, pk):
# Find all IPAddresses belonging to this Prefix
ipaddresses = IPAddress.objects.filter(vrf=prefix.vrf, address__net_contained_or_equal=str(prefix.prefix))\
.select_related('vrf', 'interface__device', 'primary_ip4_for', 'primary_ip6_for')
ipaddresses = add_available_ipaddresses(prefix.prefix, ipaddresses)
ipaddresses = add_available_ipaddresses(prefix.prefix, ipaddresses, prefix.is_pool)
ip_table = tables.IPAddressTable(ipaddresses)
ip_table.model = IPAddress
if request.user.has_perm('ipam.change_ipaddress') or request.user.has_perm('ipam.delete_ipaddress'):
ip_table.base_columns['pk'].visible = True
RequestConfig(request, paginate={'klass': EnhancedPaginator}).configure(ip_table)
@@ -429,18 +509,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,
@@ -523,12 +605,20 @@ class IPAddressEditView(PermissionRequiredMixin, ObjectEditView):
form_class = forms.IPAddressForm
fields_initial = ['address', 'vrf']
template_name = 'ipam/ipaddress_edit.html'
cancel_url = 'ipam:ipaddress_list'
obj_list_url = 'ipam:ipaddress_list'
class IPAddressDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_ipaddress'
model = IPAddress
default_return_url = 'ipam:ipaddress_list'
class IPAddressBulkAddView(PermissionRequiredMixin, BulkAddView):
permission_required = 'ipam.add_ipaddress'
form = forms.IPAddressBulkAddForm
model = IPAddress
template_name = 'ipam/ipaddress_bulk_add.html'
redirect_url = 'ipam:ipaddress_list'
@@ -586,8 +676,9 @@ class VLANGroupEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.change_vlangroup'
model = VLANGroup
form_class = forms.VLANGroupForm
success_url = 'ipam:vlangroup_list'
cancel_url = 'ipam:vlangroup_list'
def get_return_url(self, obj):
return reverse('ipam:vlangroup_list')
class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -601,7 +692,7 @@ class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
#
class VLANListView(ObjectListView):
queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')
queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role').prefetch_related('prefixes')
filter = filters.VLANFilter
filter_form = forms.VLANFilterForm
table = tables.VLANTable
@@ -612,8 +703,8 @@ class VLANListView(ObjectListView):
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)
prefixes = Prefix.objects.filter(vlan=vlan).select_related('vrf', 'site', 'role')
prefix_table = tables.PrefixBriefTable(list(prefixes))
return render(request, 'ipam/vlan.html', {
'vlan': vlan,
@@ -626,13 +717,13 @@ class VLANEditView(PermissionRequiredMixin, ObjectEditView):
model = VLAN
form_class = forms.VLANForm
template_name = 'ipam/vlan_edit.html'
cancel_url = 'ipam:vlan_list'
obj_list_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):
@@ -655,3 +746,27 @@ class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_vlan'
cls = VLAN
default_redirect_url = 'ipam:vlan_list'
#
# Services
#
class ServiceEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.change_service'
model = Service
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'])
return obj
def get_return_url(self, obj):
return obj.device.get_absolute_url()
class ServiceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_service'
model = Service

View File

@@ -12,7 +12,7 @@ except ImportError:
"the documentation.")
VERSION = '1.7.0'
VERSION = '1.8.2'
# Import local configuration
for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:
@@ -117,7 +117,8 @@ INSTALLED_APPS = (
)
# Middleware
MIDDLEWARE_CLASSES = (
MIDDLEWARE = (
'debug_toolbar.middleware.DebugToolbarMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
@@ -185,12 +186,20 @@ SECRETS_MIN_PUBKEY_SIZE = 2048
REST_FRAMEWORK = {
'DEFAULT_FILTER_BACKENDS': ('rest_framework.filters.DjangoFilterBackend',)
}
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': '{}/{}api/docs'.format(ALLOWED_HOSTS[0], BASE_PATH),
}
# Django debug toolbar
INTERNAL_IPS = (
'127.0.0.1',
'::1',
)
try:
HOSTNAME = socket.gethostname()

View File

@@ -42,6 +42,12 @@ _patterns = [
]
if settings.DEBUG:
import debug_toolbar
_patterns += [
url(r'^__debug__/', include(debug_toolbar.urls)),
]
# Prepend BASE_PATH
urlpatterns = [
url(r'^{}'.format(settings.BASE_PATH), include(_patterns))

View File

@@ -13,7 +13,7 @@ body {
}
.container {
width: auto;
max-width: 1340px;
max-width: 1600px;
}
.wrapper {
min-height: 100%;
@@ -35,7 +35,8 @@ footer p {
margin: 20px 0;
}
@media (max-width: 1200px) {
/* Collapse the nav menu on displays less than 1200px wide */
@media (max-width: 1199px) {
.navbar-header {
float: none;
}
@@ -58,7 +59,7 @@ footer p {
max-height: none;
}
.navbar-nav {
float: none!important;
float: none !important;
margin-top: 7.5px;
}
.navbar-nav>li {
@@ -85,17 +86,27 @@ label.required {
th.pk, td.pk {
width: 30px;
}
tfoot td {
font-weight: bold;
}
table.attr-table td:nth-child(1) {
width: 25%;
}
/* Paginator */
div.paginator {
margin-bottom: 20px;
}
nav ul.pagination {
margin-top: 0;
margin-bottom: 8px !important;
}
/* Racks */
div.rack_header {
margin-left: 36px;
text-align: center;
width: 200px;
width: 230px;
}
ul.rack_legend {
float: left;
@@ -123,29 +134,16 @@ ul.rack {
list-style-type: none;
padding: 0;
position: absolute;
width: 200px;
width: 230px;
}
ul.rack li {
border-top: 1px solid #e0e0e0;
display: block;
font-size: 13px;
height: 20px;
overflow: hidden;
text-align: center;
}
ul.rack_empty li {
background-color: #f7f7f7;
border-bottom: 1px solid #dddddd;
height: 20px;
}
ul.rack li.empty:last-child {
border-bottom: 0;
}
ul.rack_far_face {
z-index: 100;
}
ul.rack_near_face {
z-index: 200;
}
ul.rack li.h2u { height: 40px; }
ul.rack li.h2u a, ul.rack li.h2u span { padding: 10px 0; }
ul.rack li.h3u { height: 60px; }
@@ -244,22 +242,9 @@ ul.rack li.h49u { height: 980px; }
ul.rack li.h49u a, ul.rack li.h49u span { padding: 480px 0; }
ul.rack li.h50u { height: 1000px; }
ul.rack li.h50u a, ul.rack li.h50u span { padding: 490px 0; }
ul.rack li.occupied a {
color: #ffffff;
display: block;
font-weight: bold;
}
ul.rack li.occupied a:hover {
text-decoration: none;
}
ul.rack li.occupied span {
display: block;
}
ul.rack_near_face li.empty {
border-bottom: 1px solid #e0e0e0;
}
ul.rack_near_face li.occupied {
color: #474747;
ul.rack_far_face {
background-color: #f7f7f7;
z-index: 100;
}
ul.rack_far_face li.occupied {
background: repeating-linear-gradient(
@@ -269,7 +254,6 @@ ul.rack_far_face li.occupied {
#f0f0f0 7px,
#f0f0f0 14px
);
color: #303030;
}
ul.rack_far_face li.blocked {
background: repeating-linear-gradient(
@@ -279,54 +263,46 @@ ul.rack_far_face li.blocked {
#ffc7c7 7px,
#ffc7c7 14px
);
border-bottom: 1px solid #e0e0e0;
color: #303030;
}
ul.rack_near_face li.empty a {
ul.rack_near_face {
z-index: 200;
}
ul.rack_near_face li.occupied {
border-top: 1px solid #474747;
color: #474747;
}
ul.rack_near_face li.occupied:hover {
background-image: url('../img/tint_20.png');
}
ul.rack_near_face li:first-child {
border-top: 0;
}
ul.rack_near_face li.available a {
color: #0000ff;
display: none;
text-decoration: none;
}
ul.rack_near_face li.empty:hover {
ul.rack_near_face li.available:hover {
background-color: #ffffff;
}
ul.rack_near_face li.empty:hover a {
ul.rack_near_face li.available:hover a {
display: block;
}
/* Colors (from http://flatuicolors.com) */
.teal { background-color: #1abc9c; }
.green { background-color: #2ecc71; }
.blue { background-color: #3498db; }
.purple { background-color: #9b59b6; }
.yellow { background-color: #f1c40f; }
.orange { background-color: #e67e22; }
.red { background-color: #e74c3c; }
.light_gray { background-color: #dce2e3; }
.medium_gray { background-color: #95a5a6; }
.dark_gray { background-color: #34495e; }
/* Rack elevation coloring */
ul.rack .teal { border-bottom: 1px solid #16a085; }
ul.rack .teal:hover { background-color: #16a085; }
ul.rack .green { border-bottom: 1px solid #27ae60; }
ul.rack .green:hover { background-color: #27ae60; }
ul.rack .blue { border-bottom: 1px solid #2980b9; }
ul.rack .blue:hover { background-color: #2980b9; }
ul.rack .purple { border-bottom: 1px solid #8e44ad; }
ul.rack .purple:hover { background-color: #8e44ad; }
ul.rack .yellow { border-bottom: 1px solid #f39c12; }
ul.rack .yellow:hover { background-color: #f39c12; }
ul.rack .orange { border-bottom: 1px solid #d35400; }
ul.rack .orange:hover { background-color: #d35400; }
ul.rack .red { border-bottom: 1px solid #c0392b; }
ul.rack .red:hover { background-color: #c0392b; }
ul.rack .light_gray { border-bottom: 1px solid #bdc3c7; }
ul.rack .light_gray:hover { background-color: #bdc3c7; }
ul.rack .medium_gray { border-bottom: 1px solid #7f8c8d; }
ul.rack .medium_gray:hover { background-color: #7f8c8d; }
ul.rack .dark_gray { border-bottom: 1px solid #2c3e50; }
ul.rack .dark_gray:hover { background-color: #2c3e50; }
ul.rack li.occupied a {
color: #ffffff;
display: block;
font-weight: bold;
}
ul.rack li.occupied a:hover {
text-decoration: none;
}
ul.rack li.occupied span {
cursor: default;
display: block;
}
li.occupied + li.available {
border-top: 1px solid #474747;
}
/* Misc */
.banner-bottom {
@@ -354,4 +330,4 @@ td .progress {
}
textarea {
font-family: Consolas, Lucida Console, monospace;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 B

View File

@@ -51,6 +51,14 @@ $(document).ready(function() {
$('#id_' + this.value).toggle('disabled');
});
// Set formaction and submit using a link
$('a.formaction').click(function (event) {
event.preventDefault();
var form = $(this).closest('form');
form.attr('action', $(this).attr('href'));
form.submit();
});
// API select widget
$('select[filter-for]').change(function () {

View File

@@ -17,7 +17,7 @@ class SecretFilter(django_filters.FilterSet):
label='Role (ID)',
)
role = django_filters.ModelMultipleChoiceFilter(
name='role',
name='role__slug',
queryset=SecretRole.objects.all(),
to_field_name='slug',
label='Role (slug)',
@@ -31,7 +31,7 @@ class SecretFilter(django_filters.FilterSet):
class Meta:
model = Secret
fields = ['name', 'role_id', 'role', 'device']
fields = ['name']
def search(self, queryset, value):
return queryset.filter(

View File

@@ -34,7 +34,7 @@ def validate_rsa_key(key, is_secret=True):
# Secret roles
#
class SecretRoleForm(forms.ModelForm, BootstrapMixin):
class SecretRoleForm(BootstrapMixin, forms.ModelForm):
slug = SlugField()
class Meta:
@@ -46,7 +46,7 @@ class SecretRoleForm(forms.ModelForm, BootstrapMixin):
# Secrets
#
class SecretForm(forms.ModelForm, BootstrapMixin):
class SecretForm(BootstrapMixin, forms.ModelForm):
private_key = forms.CharField(required=False, widget=forms.HiddenInput())
plaintext = forms.CharField(max_length=65535, required=False, label='Plaintext',
widget=forms.PasswordInput(attrs={'class': 'requires-private-key'}))
@@ -85,12 +85,12 @@ class SecretFromCSVForm(forms.ModelForm):
return s
class SecretImportForm(BulkImportForm, BootstrapMixin):
class SecretImportForm(BootstrapMixin, BulkImportForm):
private_key = forms.CharField(widget=forms.HiddenInput())
csv = CSVDataField(csv_form=SecretFromCSVForm, widget=forms.Textarea(attrs={'class': 'requires-private-key'}))
class SecretBulkEditForm(BulkEditForm, BootstrapMixin):
class SecretBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Secret.objects.all(), widget=forms.MultipleHiddenInput)
role = forms.ModelChoiceField(queryset=SecretRole.objects.all(), required=False)
name = forms.CharField(max_length=100, required=False)
@@ -99,7 +99,7 @@ class SecretBulkEditForm(BulkEditForm, BootstrapMixin):
nullable_fields = ['name']
class SecretFilterForm(forms.Form, BootstrapMixin):
class SecretFilterForm(BootstrapMixin, forms.Form):
role = FilterChoiceField(queryset=SecretRole.objects.annotate(filter_count=Count('secrets')), to_field_name='slug')
@@ -107,7 +107,7 @@ class SecretFilterForm(forms.Form, BootstrapMixin):
# UserKeys
#
class UserKeyForm(forms.ModelForm, BootstrapMixin):
class UserKeyForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = UserKey

View File

@@ -30,8 +30,9 @@ class SecretRoleEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'secrets.change_secretrole'
model = SecretRole
form_class = forms.SecretRoleForm
success_url = 'secrets:secretrole_list'
cancel_url = 'secrets:secretrole_list'
def get_return_url(self, obj):
return reverse('secrets:secretrole_list')
class SecretRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -151,7 +152,7 @@ def secret_edit(request, 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')

View File

@@ -5,14 +5,14 @@
{% block content %}
<div class="row">
<div class="col-md-9">
<div class="col-sm-8 col-md-9">
<ol class="breadcrumb">
<li><a href="{% url 'circuits:circuit_list' %}">Circuits</a></li>
<li><a href="{% url 'circuits:circuit_list' %}?provider={{ circuit.provider.slug }}">{{ circuit.provider }}</a></li>
<li>{{ circuit.cid }}</li>
</ol>
</div>
<div class="col-md-3">
<div class="col-sm-4 col-md-3">
<form action="{% url 'circuits:circuit_list' %}" method="get">
<div class="input-group">
<input type="text" name="q" class="form-control" />
@@ -40,13 +40,14 @@
{% endif %}
</div>
<h1>{{ circuit.provider }} - {{ circuit.cid }}</h1>
{% include 'inc/created_updated.html' with obj=circuit %}
<div class="row">
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Circuit</strong>
</div>
<table class="table table-hover panel-body">
<table class="table table-hover panel-body attr-table">
<tr>
<td>Provider</td>
<td>
@@ -81,17 +82,6 @@
{% endif %}
</td>
</tr>
<tr>
<td>Speed</td>
<td>
{% if circuit.upstream_speed %}
<i class="fa fa-arrow-down" title="Downstream"></i> {{ circuit.port_speed_human }} &nbsp;
<i class="fa fa-arrow-up" title="Upstream"></i> {{ circuit.upstream_speed_human }}
{% else %}
{{ circuit.port_speed_human }}
{% endif %}
</td>
</tr>
<tr>
<td>Commit Rate</td>
<td>
@@ -102,72 +92,21 @@
{% 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 %}
{% include 'inc/custom_fields_panel.html' %}
{% endwith %}
{% include 'inc/created_updated.html' with obj=circuit %}
</div>
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Termination</strong>
</div>
<table class="table table-hover panel-body">
<tr>
<td>Site</td>
<td>
<a href="{% url 'dcim:site' slug=circuit.site.slug %}">{{ circuit.site }}</a>
</td>
</tr>
<tr>
<td>Termination</td>
<td>
{% if circuit.interface %}
<span><a href="{% url 'dcim:device' pk=circuit.interface.device.pk %}">{{ circuit.interface.device }}</a> {{ circuit.interface }}</span>
{% else %}
<span class="text-muted">Not defined</span>
{% endif %}
</td>
</tr>
<tr>
<td>IP Addressing</td>
<td>
{% if circuit.interface %}
{% for ip in circuit.interface.ip_addresses.all %}
{% if not forloop.first %}<br />{% endif %}
<a href="{% url 'ipam:ipaddress' pk=ip.pk %}">{{ ip }}</a> ({{ ip.vrf|default:"Global" }})
{% empty %}
<span class="text-muted">None</span>
{% endfor %}
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
</tr>
<tr>
<td>Cross-Connect</td>
<td>
{% if circuit.xconnect_id %}
{{ circuit.xconnect_id }}
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
</tr>
<tr>
<td>Patch Panel/Port</td>
<td>
{% if circuit.pp_info %}
{{ circuit.pp_info }}
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
</tr>
</table>
</div>
<div class="panel panel-default">
<div class="panel-heading">
<strong>Comments</strong>
@@ -180,6 +119,10 @@
{% endif %}
</div>
</div>
</div>
<div class="col-md-6">
{% include 'circuits/inc/circuit_termination.html' with termination=termination_a side='A' %}
{% include 'circuits/inc/circuit_termination.html' with termination=termination_z side='Z' %}
</div>
</div>
{% endblock %}

View File

@@ -1,5 +1,4 @@
{% extends 'utilities/obj_edit.html' %}
{% load static from staticfiles %}
{% load form_helpers %}
{% block form %}
@@ -11,16 +10,8 @@
{% render_field form.type %}
{% render_field form.tenant %}
{% render_field form.install_date %}
{% render_field form.xconnect_id %}
{% render_field form.pp_info %}
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Bandwidth</strong></div>
<div class="panel-body">
{% render_field form.port_speed %}
{% render_field form.upstream_speed %}
{% render_field form.commit_rate %}
{% render_field form.description %}
</div>
</div>
{% if form.custom_fields %}
@@ -31,26 +22,6 @@
</div>
</div>
{% endif %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Termination</strong></div>
<div class="panel-body">
{% render_field form.site %}
<ul class="nav nav-tabs" role="tablist">
<li role="presentation" class="active"><a href="#select" aria-controls="home" role="tab" data-toggle="tab">Select</a></li>
<li role="presentation"><a href="#search" aria-controls="search" role="tab" data-toggle="tab">Search</a></li>
</ul>
<div class="tab-content">
<div class="tab-pane active" id="select">
{% render_field form.rack %}
{% render_field form.device %}
</div>
<div class="tab-pane" id="search">
{% render_field form.livesearch %}
</div>
</div>
{% render_field form.interface %}
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Comments</strong></div>
<div class="panel-body">
@@ -58,7 +29,3 @@
</div>
</div>
{% endblock %}
{% block javascript %}
<script src="{% static 'js/livesearch.js' %}"></script>
{% endblock %}

View File

@@ -48,45 +48,25 @@
<td>Name of tenant (optional)</td>
<td>Strickland Propane</td>
</tr>
<tr>
<td>Site</td>
<td>Site name</td>
<td>ASH-4</td>
</tr>
<tr>
<td>Install Date</td>
<td>Date in YYYY-MM-DD format (optional)</td>
<td>2016-02-23</td>
</tr>
<tr>
<td>Port Speed</td>
<td>Physical speed in Kbps</td>
<td>100000</td>
</tr>
<tr>
<td>Upstream Speed</td>
<td>Upstream speed in Kbps (optional)</td>
<td>20000</td>
</tr>
<tr>
<td>Commit rate</td>
<td>Commited rate in Kbps (optional)</td>
<td>2000</td>
</tr>
<tr>
<td>Cross-connect ID</td>
<td>ID of cross-connect (optional)</td>
<td>937649</td>
</tr>
<tr>
<td>Patch Panel</td>
<td>Patch panel/port ID (optional)</td>
<td>PP8371 ports 13/14</td>
<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,ASH-4,2016-02-23,100000,,2000,937649,PP8371 ports 13/14</pre>
<pre>IC-603122,TeliaSonera,Transit,Strickland Propane,2016-02-23,2000,Primary for voice</pre>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,25 @@
{% extends 'utilities/confirmation_form.html' %}
{% block title %}Swap Circuit Terminations{% endblock %}
{% block message %}
<p>Swap these terminations for circuit {{ circuit }}?</p>
<ul>
<li>
<strong>A side:</strong>
{% if termination_a %}
{{ termination_a.site }} {% if termination_a.interface %}- {{ termination_a.interface.device }} {{ termination_a.interface }}{% endif %}
{% else %}
<span class="text-muted">None</span>
{% endif %}
</li>
<li>
<strong>Z side:</strong>
{% if termination_z %}
{{ termination_z.site }} {% if termination_z.interface %}- {{ termination_z.interface.device }} {{ termination_z.interface }}{% endif %}
{% else %}
<span class="text-muted">None</span>
{% endif %}
</li>
</ul>
{% endblock %}

View File

@@ -0,0 +1,94 @@
{% extends '_base.html' %}
{% load staticfiles %}
{% load form_helpers %}
{% block title %}
Circuit {{ obj.circuit }} - Side {{ form.term_side.value }}
{% endblock %}
{% 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">
<h3>Circuit {{ obj.circuit }} - Side {{ form.term_side.value }}</h3>
{% if form.non_field_errors %}
<div class="panel panel-danger">
<div class="panel-heading"><strong>Errors</strong></div>
<div class="panel-body">
{{ form.non_field_errors }}
</div>
</div>
{% endif %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Location</strong></div>
<div class="panel-body">
<div class="form-group">
<label class="col-md-3 control-label">Provider</label>
<div class="col-md-9">
<p class="form-control-static">{{ obj.circuit.provider }}</p>
</div>
</div>
<div class="form-group">
<label class="col-md-3 control-label">Circuit</label>
<div class="col-md-9">
<p class="form-control-static">{{ obj.circuit.cid }}</p>
</div>
</div>
<div class="form-group">
<label class="col-md-3 control-label">Termination</label>
<div class="col-md-9">
<p class="form-control-static">{{ form.term_side.value }}</p>
</div>
</div>
{% render_field form.site %}
<div class="row">
<div class="col-md-9 col-md-offset-3">
<ul class="nav nav-tabs" role="tablist">
<li role="presentation" class="active"><a href="#select" aria-controls="home" role="tab" data-toggle="tab">Select</a></li>
<li role="presentation"><a href="#search" aria-controls="search" role="tab" data-toggle="tab">Search</a></li>
</ul>
</div>
</div>
<div class="tab-content">
<div class="tab-pane active" id="select">
{% render_field form.rack %}
{% render_field form.device %}
</div>
<div class="tab-pane" id="search">
{% render_field form.livesearch %}
</div>
</div>
{% render_field form.interface %}
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Termination Details</strong></div>
<div class="panel-body">
{% render_field form.port_speed %}
{% render_field form.upstream_speed %}
{% render_field form.xconnect_id %}
{% render_field form.pp_info %}
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6 col-md-offset-3 text-right">
{% if obj.pk %}
<button type="submit" name="_update" class="btn btn-primary">Update</button>
{% else %}
<button type="submit" name="_create" class="btn btn-primary">Create</button>
{% endif %}
<a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
</div>
</div>
</form>
{% endblock %}
{% block javascript %}
<script src="{% static 'js/livesearch.js' %}"></script>
{% endblock %}

View File

@@ -0,0 +1,95 @@
<div class="panel panel-default">
<div class="panel-heading">
<div class="pull-right">
{% if not termination and perms.circuits.add_circuittermination %}
<a href="{% url 'circuits:circuittermination_add' circuit=circuit.pk %}?term_side={{ side }}" class="btn btn-xs btn-success">
<span class="fa fa-plus" aria-hidden="true"></span> Add
</a>
{% endif %}
{% if termination and perms.circuits.change_circuittermination %}
<a href="{% url 'circuits:circuittermination_edit' pk=termination.pk %}" class="btn btn-xs btn-warning">
<span class="fa fa-pencil" aria-hidden="true"></span> Edit
</a>
<a href="{% url 'circuits:circuit_terminations_swap' pk=circuit.pk %}" class="btn btn-xs btn-primary">
<span class="fa fa-refresh" aria-hidden="true"></span> Swap
</a>
{% endif %}
{% if termination and perms.circuits.delete_circuittermination %}
<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 %}
</div>
<strong>Termination - {{ side }} Side</strong>
</div>
{% if termination %}
<table class="table table-hover panel-body attr-table">
<tr>
<td>Site</td>
<td>
<a href="{% url 'dcim:site' slug=termination.site.slug %}">{{ termination.site }}</a>
</td>
</tr>
<tr>
<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>
{% else %}
<span class="text-muted">Not defined</span>
{% endif %}
</td>
</tr>
<tr>
<td>Speed</td>
<td>
{% if termination.upstream_speed %}
<i class="fa fa-arrow-down" title="Downstream"></i> {{ termination.port_speed_human }} &nbsp;
<i class="fa fa-arrow-up" title="Upstream"></i> {{ termination.upstream_speed_human }}
{% else %}
{{ termination.port_speed_human }}
{% endif %}
</td>
</tr>
<tr>
<td>IP Addressing</td>
<td>
{% if termination.interface %}
{% for ip in termination.interface.ip_addresses.all %}
{% if not forloop.first %}<br />{% endif %}
<a href="{% url 'ipam:ipaddress' pk=ip.pk %}">{{ ip }}</a> ({{ ip.vrf|default:"Global" }})
{% empty %}
<span class="text-muted">None</span>
{% endfor %}
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
</tr>
<tr>
<td>Cross-Connect</td>
<td>
{% if termination.xconnect_id %}
{{ termination.xconnect_id }}
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
</tr>
<tr>
<td>Patch Panel/Port</td>
<td>
{% if termination.pp_info %}
{{ termination.pp_info }}
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
</tr>
</table>
{% else %}
<div class="panel-body">
<span class="text-muted">None</span>
</div>
{% endif %}
</div>

View File

@@ -6,13 +6,13 @@
{% block content %}
<div class="row">
<div class="col-md-9">
<div class="col-sm-8 col-md-9">
<ol class="breadcrumb">
<li><a href="{% url 'circuits:provider_list' %}">Providers</a></li>
<li>{{ provider }}</li>
</ol>
</div>
<div class="col-md-3">
<div class="col-sm-4 col-md-3">
<form action="{% url 'circuits:provider_list' %}" method="get">
<div class="input-group">
<input type="text" name="q" class="form-control" />
@@ -46,13 +46,14 @@
{% endif %}
</div>
<h1>{{ provider }}</h1>
{% include 'inc/created_updated.html' with obj=provider %}
<div class="row">
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Provider</strong>
</div>
<table class="table table-hover panel-body">
<table class="table table-hover panel-body attr-table">
<tr>
<td>ASN</td>
<td>
@@ -120,7 +121,6 @@
{% endif %}
</div>
</div>
{% include 'inc/created_updated.html' with obj=provider %}
</div>
<div class="col-md-6">
<div class="panel panel-default">
@@ -134,14 +134,8 @@
<a href="{% url 'circuits:circuit' pk=c.pk %}">{{ c.cid }}</a>
</td>
<td>
<a href="{% url 'dcim:site' slug=c.site.slug %}">{{ c.site }}</a>
<a href="{% url 'circuits:circuit_list' %}?type={{ c.type.slug }}">{{ c.type }}</a>
</td>
<td>
{% if c.interface %}
<a href="{% url 'dcim:device' pk=c.interface.device.pk %}">{{ c.interface.device }}</a>
{% endif %}
</td>
<td>{{ c.port_speed_human }}</td>
</tr>
{% empty %}
<tr>
@@ -149,6 +143,13 @@
</tr>
{% endfor %}
</table>
{% if perms.circuits.add_circuit %}
<div class="panel-footer text-right">
<a href="{% url 'circuits:circuit_add' %}?provider={{ provider.pk }}" class="btn btn-xs btn-primary">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add circuit
</a>
</div>
{% endif %}
</div>
</div>
</div>

View File

@@ -1,13 +0,0 @@
{% extends 'utilities/confirmation_form.html' %}
{% load form_helpers %}
{% block title %}Delete device type components?{% endblock %}
{% block message %}
<p>Are you sure you want to delete these components from <strong>{{ devicetype }}</strong>?</p>
<ul>
{% for o in selected_objects %}
<li>{{ o }}</li>
{% endfor %}
</ul>
{% endblock %}

View File

@@ -6,14 +6,14 @@
{% 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-6">
<div class="col-md-5 col-lg-6">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Device</strong>
</div>
<table class="table table-hover panel-body">
<table class="table table-hover panel-body attr-table">
<tr>
<td>Tenant</td>
<td>
@@ -56,7 +56,7 @@
<tr>
<td>Device Type</td>
<td>
<span><a href="{% url 'dcim:devicetype' pk=device.device_type.pk %}">{{ device.device_type }}</a> ({{ device.device_type.u_height }}U)</span>
<span><a href="{% url 'dcim:devicetype' pk=device.device_type.pk %}">{{ device.device_type.full_name }}</a> ({{ device.device_type.u_height }}U)</span>
</td>
</tr>
<tr>
@@ -85,7 +85,7 @@
<div class="panel-heading">
<strong>Management</strong>
</div>
<table class="table table-hover panel-body">
<table class="table table-hover panel-body attr-table">
<tr>
<td>Role</td>
<td>
@@ -183,7 +183,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 %}
@@ -205,13 +205,36 @@
{% endif %}
{% endif %}
</div>
<div class="panel panel-default">
<div class="panel-heading">
<strong>Services</strong>
</div>
{% if services %}
<table class="table table-hover panel-body">
{% for service in services %}
{% include 'dcim/inc/service.html' %}
{% endfor %}
</table>
{% else %}
<div class="panel-body text-muted">
None
</div>
{% endif %}
{% if perms.dcim.add_service %}
<div class="panel-footer text-right">
<a href="{% url 'dcim:service_assign' device=device.pk %}" class="btn btn-xs btn-primary">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Assign service
</a>
</div>
{% endif %}
</div>
<div class="panel panel-default">
<div class="panel-heading">
<strong>Critical Connections</strong>
</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">
@@ -223,7 +246,7 @@
</tr>
{% endfor %}
{% for cp in console_ports %}
{% include 'dcim/inc/_consoleport.html' %}
{% include 'dcim/inc/consoleport.html' %}
{% empty %}
<tr>
<td colspan="5" class="alert-warning">
@@ -235,18 +258,16 @@
</tr>
{% endfor %}
{% for pp in power_ports %}
{% include 'dcim/inc/_powerport.html' %}
{% include 'dcim/inc/powerport.html' %}
{% empty %}
{% if not device.device_type.is_pdu %}
<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>
{% endif %}
<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>
{% endfor %}
</table>
{% if perms.dcim.add_interface or perms.dcim.add_consoleport or perms.dcim.add_powerport %}
@@ -261,7 +282,7 @@
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console port
</a>
{% endif %}
{% if perms.dcim.add_powerport and not device.device_type.is_pdu %}
{% if perms.dcim.add_powerport %}
<a href="{% url 'dcim:powerport_add' pk=device.pk %}" class="btn btn-xs btn-primary">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power port
</a>
@@ -295,7 +316,7 @@
<td>
<a href="{% url 'dcim:rack' pk=rd.rack.pk %}">Rack {{ rd.rack }}</a>
</td>
<td>{{ rd.device_type }}</td>
<td>{{ rd.device_type.full_name }}</td>
</tr>
{% endfor %}
</table>
@@ -303,9 +324,8 @@
<div class="panel-body text-muted">None found</div>
{% endif %}
</div>
{% include 'inc/created_updated.html' with obj=device %}
</div>
<div class="col-md-6">
<div class="col-md-7 col-lg-6">
{% if device_bays or device.device_type.is_parent_device %}
{% if perms.dcim.delete_devicebay %}
<form method="post" action="{% url 'dcim:devicebay_bulk_delete' pk=device.pk %}">
@@ -315,9 +335,11 @@
<div class="panel-heading">
<strong>Device Bays</strong>
<div class="pull-right">
<button class="btn btn-default btn-xs toggle">
<span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
</button>
{% if perms.dcim.change_devicebay and device_bays|length > 1 %}
<button class="btn btn-default btn-xs toggle">
<span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
</button>
{% endif %}
{% if perms.dcim.add_devicebay and device_bays|length > 10 %}
<a href="{% url 'dcim:devicebay_add' pk=device.pk %}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add device bays
@@ -327,7 +349,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>
@@ -365,9 +387,11 @@
<div class="panel-heading">
<strong>Interfaces</strong>
<div class="pull-right">
<button class="btn btn-default btn-xs toggle">
<span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
</button>
{% if perms.dcim.change_interface and interfaces|length > 1 %}
<button class="btn btn-default btn-xs toggle">
<span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
</button>
{% endif %}
{% if perms.dcim.add_interface and interfaces|length > 10 %}
<a href="{% url 'dcim:interface_add' pk=device.pk %}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
@@ -377,7 +401,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>
@@ -420,9 +444,11 @@
<div class="panel-heading">
<strong>Console Server Ports</strong>
<div class="pull-right">
<button class="btn btn-default btn-xs toggle">
<span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
</button>
{% if perms.dcim.change_consoleserverport and cs_ports|length > 1 %}
<button class="btn btn-default btn-xs toggle">
<span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
</button>
{% endif %}
{% if perms.dcim.add_consoleserverport and cs_ports|length > 10 %}
<a href="{% url 'dcim:consoleserverport_add' pk=device.pk %}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console server ports
@@ -432,7 +458,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>
@@ -470,9 +496,11 @@
<div class="panel-heading">
<strong>Power Outlets</strong>
<div class="pull-right">
<button class="btn btn-default btn-xs toggle">
<span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
</button>
{% if perms.dcim.change_poweroutlet and cs_ports|length > 1 %}
<button class="btn btn-default btn-xs toggle">
<span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
</button>
{% endif %}
{% if perms.dcim.add_poweroutlet and power_outlets|length > 10 %}
<a href="{% url 'dcim:poweroutlet_add' pk=device.pk %}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power outlets
@@ -482,7 +510,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>

View File

@@ -24,7 +24,7 @@
{% for device in selected_devices %}
<tr>
<td><a href="{% url 'dcim:device' pk=device.pk %}">{{ device }}</a></td>
<td>{{ device.device_type }}</td>
<td>{{ device.device_type.full_name }}</td>
<td>{{ device.device_role }}</td>
</tr>
{% endfor %}

View File

@@ -14,7 +14,7 @@
{% for device in selected_objects %}
<tr>
<td><a href="{% url 'dcim:device' pk=device.pk %}">{{ device }}</a></td>
<td>{{ device.device_type }}</td>
<td>{{ device.device_type.full_name }}</td>
<td>{{ device.device_role }}</td>
<td>{{ device.tenant }}</td>
<td>{{ device.serial }}</td>

View File

@@ -1,9 +1,9 @@
{% extends '_base.html' %}
{% load form_helpers %}
{% block title %}Create {{ component_type }} ({{ device }}){% endblock %}
{% block title %}Create {{ component_type }} ({{ parent }}){% endblock %}
{% block content %}
{% block content %}{{ form.errors }}
<form action="." method="post" class="form form-horizontal">
{% csrf_token %}
<div class="row">
@@ -18,13 +18,13 @@
{% endif %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>{{ component_type }}</strong>
<strong>{{ component_type|title }}</strong>
</div>
<div class="panel-body">
<div class="form-group">
<label class="col-md-3 control-label required">Device</label>
<div class="col-md-9">
<p class="form-control-static">{{ device }}</p>
<p class="form-control-static">{{ parent }}</p>
</div>
</div>
{% render_form form %}

View File

@@ -57,7 +57,7 @@
<div class="panel-body">
{% render_field form.platform %}
{% render_field form.status %}
{% if obj %}
{% if obj.pk %}
{% render_field form.primary_ip4 %}
{% render_field form.primary_ip6 %}
{% endif %}

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">

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">

View File

@@ -3,17 +3,17 @@
{% 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">
<div class="panel-heading">
<strong>Chassis</strong>
</div>
<table class="table table-hover panel-body">
<table class="table table-hover panel-body attr-table">
<tr>
<td>Model</td>
<td>{{ device.device_type }}</td>
<td>{{ device.device_type.full_name }}</td>
</tr>
<tr>
<td>Serial Number</td>
@@ -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>
@@ -127,7 +127,7 @@
</table>
</div>
{% if perms.dcim.add_module %}
<a href="{% url 'dcim:module_add' pk=device.pk %}" class="btn btn-success">
<a href="{% url 'dcim:module_add' device=device.pk %}" class="btn btn-success">
<span class="fa fa-plus" aria-hidden="true"></span>
Add a Module
</a>

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>
@@ -23,7 +23,7 @@
<tr id="{{ iface }}">
<td>{{ iface }}</td>
{% if iface.connection %}
{% with iface.get_connected_interface as connected_iface %}
{% with iface.connected_interface as connected_iface %}
<td class="configured_device" data="{{ connected_iface.device }}">
<a href="{% url 'dcim:device' pk=connected_iface.device.pk %}">{{ connected_iface.device }}</a>
</td>

View File

@@ -2,7 +2,7 @@
{% load helpers %}
{% load render_table from django_tables2 %}
{% block title %}{{ devicetype }}{% endblock %}
{% block title %}{{ devicetype.manufacturer }} {{ devicetype.model }}{% endblock %}
{% block content %}
<div class="row">
@@ -32,14 +32,14 @@
</div>
{% endif %}
<h1>{{ devicetype }}</h1>
<h1>{{ devicetype.manufacturer }} {{ devicetype.model }}</h1>
<div class="row">
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Chassis</strong>
</div>
<table class="table table-hover panel-body">
<table class="table table-hover panel-body attr-table">
<tr>
<td>Manufacturer</td>
<td><a href="{% url 'dcim:devicetype_list' %}?manufacturer={{ devicetype.manufacturer.slug }}">{{ devicetype.manufacturer }}</a></td>
@@ -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>
@@ -145,6 +149,21 @@
</tr>
</table>
</div>
{% with devicetype.get_custom_fields as custom_fields %}
{% include 'inc/custom_fields_panel.html' %}
{% endwith %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>Comments</strong>
</div>
<div class="panel-body">
{% if devicetype.comments %}
{{ devicetype.comments|gfm }}
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
</div>
{% include 'dcim/inc/devicetype_component_table.html' with table=consoleport_table title='Console Ports' add_url='dcim:devicetype_add_consoleport' delete_url='dcim:devicetype_delete_consoleport' %}
{% include 'dcim/inc/devicetype_component_table.html' with table=powerport_table title='Power Ports' add_url='dcim:devicetype_add_powerport' delete_url='dcim:devicetype_delete_powerport' %}
{% include 'dcim/inc/devicetype_component_table.html' with table=mgmt_interface_table title='Management Interfaces' add_url='dcim:devicetype_add_interface' add_url_extra='?mgmt_only=1' edit_url='dcim:devicetype_bulkedit_interface' delete_url='dcim:devicetype_delete_interface' %}

View File

@@ -1,7 +1,7 @@
{% extends '_base.html' %}
{% load form_helpers %}
{% block title %}Add {{ component_type }} to {{ devicetype }}{% endblock %}
{% block title %}Add {{ component_type }} to {{ parent }}{% endblock %}
{% block content %}
<form action="." method="post" class="form form-horizontal">
@@ -24,7 +24,7 @@
<div class="form-group">
<label class="col-md-3 control-label required">Device Type</label>
<div class="col-md-9">
<p class="form-control-static">{{ devicetype }}</p>
<p class="form-control-static">{{ parent }}</p>
</div>
</div>
{% render_form form %}

View File

@@ -0,0 +1,40 @@
{% extends 'utilities/obj_edit.html' %}
{% load form_helpers %}
{% block form %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Device Type</strong></div>
<div class="panel-body">
{% render_field form.manufacturer %}
{% render_field form.model %}
{% render_field form.slug %}
{% 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 %}
{% render_field form.subdevice_role %}
</div>
</div>
{% if form.custom_fields %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div>
<div class="panel-body">
{% render_custom_fields form %}
</div>
</div>
{% endif %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Comments</strong></div>
<div class="panel-body">
{% render_field form.comments %}
</div>
</div>
{% endblock %}

View File

@@ -18,6 +18,7 @@
{% include 'utilities/obj_table.html' with bulk_edit_url='dcim:devicetype_bulk_edit' bulk_delete_url='dcim:devicetype_bulk_delete' %}
</div>
<div class="col-md-3">
{% include 'inc/search_panel.html' %}
{% include 'inc/filter_panel.html' %}
</div>
</div>

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>
@@ -50,7 +50,7 @@
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</button>
{% else %}
<a href="{% url 'dcim:consoleport_delete' pk=cp.pk %}" class="btn btn-danger btn-xs">
<a href="{% url 'dcim:consoleport_delete' pk=cp.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete port"></i>
</a>
{% endif %}

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>
@@ -49,7 +49,7 @@
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</button>
{% else %}
<a href="{% url 'dcim:consoleserverport_delete' pk=csp.pk %}" class="btn btn-danger btn-xs">
<a href="{% url 'dcim:consoleserverport_delete' pk=csp.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete port"></i>
</a>
{% endif %}

View File

@@ -1,5 +1,5 @@
<div class="row">
<div class="col-md-9">
<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>
@@ -13,7 +13,7 @@
</ol>
{% endif %}
</div>
<div class="col-md-3">
<div class="col-sm-4 col-md-3">
<form action="{% url 'dcim:device_list' %}" method="get">
<div class="input-group">
<input type="text" name="q" class="form-control" placeholder="Search devices" />
@@ -41,6 +41,7 @@
{% endif %}
</div>
<h1>{{ device }}</h1>
{% include 'inc/created_updated.html' with obj=device %}
<ul class="nav nav-tabs" style="margin-bottom: 20px">
<li role="presentation"{% if active_tab == 'info' %} class="active"{% endif %}><a href="{% url 'dcim:device' pk=device.pk %}">Info</a></li>
<li role="presentation"{% if active_tab == 'inventory' %} class="active"{% endif %}><a href="{% url 'dcim:device_inventory' pk=device.pk %}">Inventory</a></li>

View File

@@ -1,9 +1,19 @@
{% extends 'utilities/obj_table.html' %}
{% block extra_actions %}
{% if perms.dcim.add_interface %}
<button type="submit" name="_edit" formaction="{% url 'dcim:device_bulk_add_interface' %}" class="btn btn-primary btn-sm">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add Interfaces
</button>
{% if perms.dcim.change_device %}
<div class="btn-group">
<button type="button" class="btn btn-sm btn-primary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<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 %}
</ul>
</div>
{% endif %}
{% endblock %}

View File

@@ -1,5 +1,5 @@
<tr>
{% if selectable and perms.dcim.delete_devicebay %}
{% if selectable and perms.dcim.change_devicebay or perms.dcim.delete_devicebay %}
<td class="pk">
<input name="pk" type="checkbox" value="{{ devicebay.pk }}" />
</td>
@@ -12,7 +12,7 @@
<a href="{% url 'dcim:device' pk=devicebay.installed_device.pk %}">{{ devicebay.installed_device }}</a>
</td>
<td>
<span>{{ devicebay.installed_device.device_type }}</span>
<span>{{ devicebay.installed_device.device_type.full_name }}</span>
</td>
{% else %}
<td colspan="2">
@@ -40,7 +40,7 @@
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</button>
{% else %}
<a href="{% url 'dcim:devicebay_delete' pk=devicebay.pk %}" class="btn btn-danger btn-xs">
<a href="{% url 'dcim:devicebay_delete' pk=devicebay.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete device bay"></i>
</a>
{% endif %}

View File

@@ -6,7 +6,7 @@
<div class="panel-heading">
<strong>{{ title }}</strong>
<div class="pull-right">
{% if table.rows|length > 3 %}
{% if table.rows|length > 1 %}
<button class="btn btn-default btn-xs toggle">
<span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
</button>

View File

@@ -1,5 +1,5 @@
<tr{% if iface.connection and not iface.connection.connection_status %} class="info"{% endif %}>
{% if selectable and perms.dcim.delete_interface %}
{% if selectable and perms.dcim.change_interface or perms.dcim.delete_interface %}
<td class="pk">
<input name="pk" type="checkbox" value="{{ iface.pk }}" />
</td>
@@ -14,9 +14,9 @@
<small>{{ iface.mac_address|default:'' }}</small>
</td>
{% if not iface.is_physical %}
<td colspan="2">Virtual</td>
<td colspan="2" class="text-muted">Virtual interface</td>
{% elif iface.connection %}
{% with iface.get_connected_interface as connected_iface %}
{% with iface.connected_interface as connected_iface %}
<td>
<a href="{% url 'dcim:device' pk=connected_iface.device.pk %}">{{ connected_iface.device }}</a>
</td>
@@ -24,10 +24,16 @@
<span title="{{ connected_iface.get_form_factor_display }}">{{ connected_iface }}</span>
</td>
{% endwith %}
{% elif iface.circuit %}
<td colspan="2">
<a href="{% url 'circuits:circuit' pk=iface.circuit.pk %}">{{ iface.circuit }}</a>
</td>
{% elif iface.circuit_termination %}
{% with iface.circuit_termination.get_peer_termination as peer_termination %}
<td colspan="2">
<i class="fa fa-fw fa-globe" title="Circuit"></i>
{% if peer_termination %}
<a href="{% url 'dcim:site' slug=peer_termination.site.slug %}">{{ peer_termination.site }}</a> via
{% endif %}
<a href="{% url 'circuits:circuit' pk=iface.circuit_termination.circuit_id %}">{{ iface.circuit_termination.circuit }}</a>
</td>
{% endwith %}
{% else %}
<td colspan="2">
<span class="text-muted">Not connected</span>
@@ -35,7 +41,7 @@
{% endif %}
<td class="text-right">
{% if show_graphs %}
{% if iface.circuit or iface.connection %}
{% if iface.circuit_termination or iface.connection %}
<button type="button" class="btn btn-primary btn-xs" data-toggle="modal" data-target="#graphs_modal" data-obj="{{ device.name }} - {{ iface.name }}" data-url="{% url 'dcim-api:interface_graphs' pk=iface.pk %}" title="Show graphs">
<i class="glyphicon glyphicon-signal" aria-hidden="true"></i>
</button>
@@ -56,12 +62,15 @@
<a href="{% url 'dcim:interfaceconnection_delete' pk=iface.connection.pk %}?device={{ device.pk }}" class="btn btn-danger btn-xs" title="Delete connection">
<i class="glyphicon glyphicon-remove" aria-hidden="true"></i>
</a>
{% elif iface.circuit and perms.circuits.change_circuit %}
<a href="{% url 'circuits:circuit_edit' pk=iface.circuit.pk %}" class="btn btn-danger btn-xs" title="Edit circuit">
{% elif iface.circuit_termination and perms.circuits.change_circuittermination %}
<button class="btn btn-warning btn-xs interface-toggle connected" disabled="disabled" title="Circuits cannot be marked as planned or connected">
<i class="glyphicon glyphicon-ban-circle" aria-hidden="true"></i>
</button>
<a href="{% url 'circuits:circuittermination_edit' pk=iface.circuit_termination.pk %}" class="btn btn-danger btn-xs" title="Edit circuit termination">
<i class="glyphicon glyphicon-remove" aria-hidden="true"></i>
</a>
{% else %}
<a href="{% url 'dcim:interfaceconnection_add' pk=device.pk %}?interface={{ iface.pk }}" class="btn btn-success btn-xs" title="Connect">
<a href="{% url 'dcim:interfaceconnection_add' pk=device.pk %}?interface_a={{ iface.pk }}" class="btn btn-success btn-xs" title="Connect">
<i class="glyphicon glyphicon-plus" aria-hidden="true"></i>
</a>
{% endif %}
@@ -71,12 +80,12 @@
</a>
{% endif %}
{% if perms.dcim.delete_interface %}
{% if iface.connection or iface.circuit %}
{% if iface.connection or iface.circuit_termination %}
<button class="btn btn-danger btn-xs" disabled="disabled">
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</button>
{% else %}
<a href="{% url 'dcim:interface_delete' pk=iface.pk %}" class="btn btn-danger btn-xs" title="Delete interface">
<a href="{% url 'dcim:interface_delete' pk=iface.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs" title="Delete interface">
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</a>
{% endif %}

View File

@@ -13,7 +13,7 @@
</td>
<td class="text-right">
{% if perms.ipam.delete_ipaddress %}
<a href="{% url 'ipam:ipaddress_delete' pk=ip.pk %}" class="btn btn-danger btn-xs">
<a href="{% url 'ipam:ipaddress_delete' pk=ip.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete IP address"></i>
</a>
{% endif %}

View File

@@ -1,5 +1,5 @@
<tr{% if po.connected_port and not po.connected_port.connection_status %} class="info"{% endif %}>
{% if selectable and perms.dcim.delete_poweroutlet %}
{% if selectable and perms.dcim.change_poweroutlet or perms.dcim.delete_poweroutlet %}
<td class="pk">
<input name="pk" type="checkbox" value="{{ po.pk }}" />
</td>
@@ -49,7 +49,7 @@
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</button>
{% else %}
<a href="{% url 'dcim:poweroutlet_delete' pk=po.pk %}" class="btn btn-danger btn-xs">
<a href="{% url 'dcim:poweroutlet_delete' pk=po.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete outlet"></i>
</a>
{% endif %}

View File

@@ -1,5 +1,5 @@
<tr{% if pp.power_outlet and not pp.connection_status %} class="info"{% endif %}>
{% if selectable and perms.dcim.delete_powerport %}
{% if selectable and perms.dcim.change_powerport or perms.dcim.delete_powerport %}
<td class="pk">
<input name="pk" type="checkbox" value="{{ pp.pk }}" />
</td>
@@ -50,7 +50,7 @@
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</button>
{% else %}
<a href="{% url 'dcim:powerport_delete' pk=pp.pk %}" class="btn btn-danger btn-xs">
<a href="{% url 'dcim:powerport_delete' pk=pp.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete port"></i>
</a>
{% endif %}

View File

@@ -6,13 +6,6 @@
<div class="rack_frame">
<!-- Render all slots empty -->
<ul class="rack rack_empty">
{% for u in rack.units %}
<li></li>
{% endfor %}
</ul>
<!-- Render rear view of devices on far face -->
<ul class="rack rack_far_face">
{% for u in secondary_face %}
@@ -28,10 +21,10 @@
<ul class="rack rack_near_face">
{% for u in primary_face %}
{% if u.device %}
<li class="occupied h{{ u.device.device_type.u_height }}u{% ifequal u.device.face face_id %} {{ u.device.device_role.color }}{% endifequal %}">
<li class="occupied h{{ u.device.device_type.u_height }}u"{% ifequal u.device.face face_id %} style="background-color: #{{ u.device.device_role.color }}"{% endifequal %}>
{% ifequal u.device.face face_id %}
<a href="{% url 'dcim:device' pk=u.device.pk %}" data-toggle="popover" data-trigger="hover" data-container="body" data-html="true"
data-content="{{ u.device.device_role }}<br />{{ u.device.device_type }} ({{ u.device.device_type.u_height }}U)">
data-content="{{ u.device.device_role }}<br />{{ u.device.device_type.full_name }} ({{ u.device.device_type.u_height }}U)">
{{ u.device.name|default:u.device.device_role }}
{% if u.device.devicebay_count %}
({{ u.device.get_children.count }}/{{ u.device.devicebay_count }})
@@ -42,7 +35,7 @@
{% endifequal %}
</li>
{% else %}
<li class="empty">
<li class="available">
{% if perms.dcim.add_device %}
<a href="{% url 'dcim:device_add' %}?site={{ rack.site.pk }}&rack={{ rack.pk }}&face={{ face_id }}&position={{ u.id }}" class="add_device" >add device</a>
{% endif %}

View File

@@ -0,0 +1,26 @@
<tr>
<td>{{ service.name }}</td>
<td>
{{ service.get_protocol_display }}/{{ service.port }}
</td>
<td>
{% for ip in service.ipaddresses.all %}
<span>{{ ip.address.ip }}</span><br />
{% empty %}
<span class="text-muted">All IPs</span>
{% endfor %}
</td>
<td>{{ service.description }}</td>
<td class="text-right">
{% if perms.ipam.change_service %}
<a href="{% url 'ipam:service_edit' pk=service.pk %}" class="btn btn-info btn-xs" title="Edit service">
<i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
</a>
{% endif %}
{% if perms.ipam.delete_service %}
<a href="{% url 'ipam:service_delete' pk=service.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete service"></i>
</a>
{% endif %}
</td>
</tr>

View File

@@ -27,6 +27,12 @@
<strong>A Side</strong>
</div>
<div class="panel-body">
<div class="form-group">
<label class="col-md-3 control-label required">Site</label>
<div class="col-md-9">
<p class="form-control-static">{{ device.rack.site }}</p>
</div>
</div>
<div class="form-group">
<label class="col-md-3 control-label required">Rack</label>
<div class="col-md-9">
@@ -61,6 +67,7 @@
{% render_field form.livesearch %}
</div>
<div class="tab-pane" id="select">
{% render_field form.site_b %}
{% render_field form.rack_b %}
{% render_field form.device_b %}
</div>
@@ -77,8 +84,8 @@
</div>
<div class="text-center">
<div class="form-group">
<button type="submit" name="_create" class="btn btn-primary">Create</button>
<button type="submit" name="_addanother" class="btn btn-primary">Create and Connect Another</button>
<button type="submit" name="_create" class="btn btn-primary">Connect</button>
<button type="submit" name="_addanother" class="btn btn-primary">Connect and Add Another</button>
<a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
</div>
</div>

View File

@@ -1,7 +1,7 @@
{% extends '_base.html' %}
{% load form_helpers %}
{% block title %}Assign an IP Address{% endblock %}
{% block title %}Assign a New IP Address{% endblock %}
{% block content %}
<form action="." method="post" class="form form-horizontal">
@@ -40,6 +40,7 @@
</div>
</div>
{% render_field form.interface %}
{% render_field form.set_as_primary %}
</div>
</div>
<div class="panel panel-default">

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