Compare commits

..

218 Commits

Author SHA1 Message Date
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
Jeremy Stretch
57ddd5086f Merge pull request #666 from digitalocean/develop
Release v1.7.0
2016-11-03 15:12:33 -04:00
Jeremy Stretch
6c1fb1bd02 Release v1.7.0 2016-11-03 15:06:08 -04:00
Jeremy Stretch
ea92e92c5a Fixes #632: Use semicolons instead of commas to separate regexes in topology maps 2016-11-03 14:49:02 -04:00
Jeremy Stretch
96eaea7db9 Miscellaneous cleanup 2016-11-03 14:15:57 -04:00
Jeremy Stretch
bbac6e2ba6 Fixes #664: Re-implemented view for bulk creation of interfaces across multiple devices 2016-11-02 16:43:24 -04:00
Jeremy Stretch
76c6fbbfba Merge pull request #653 from flokli/gitignore
gitignore static folder, concretize configuration.py location
2016-11-01 15:44:49 -04:00
Jeremy Stretch
ad1c3d4910 Fixed typo in error message 2016-11-01 15:44:10 -04:00
Jeremy Stretch
f2137683f9 Closes #647: Extend form used when assigning an IP to a device 2016-11-01 13:59:24 -04:00
Jeremy Stretch
084b86cab1 Tweaked Aggregate get_utilization for table display 2016-10-31 16:47:00 -04:00
Jeremy Stretch
41af9c8900 Fixes #660: Correct calculation of utilized space for rack list display 2016-10-31 16:44:00 -04:00
Jeremy Stretch
2d58cfaa05 Add is_full_depth and instance count columns to DeviceType table 2016-10-31 15:29:32 -04:00
Jeremy Stretch
4af3072b53 Fix typo in c525939b13 2016-10-31 14:48:33 -04:00
Jeremy Stretch
a37d2ff4f8 Closes #652: Use password input controls when editing secrets 2016-10-31 12:30:50 -04:00
Jeremy Stretch
c525939b13 Closes #654: Added Cisco FlexStack and FlexStack Plus form factors 2016-10-31 12:22:05 -04:00
Jeremy Stretch
dc186a57cd Closes #661: Display relevant IP addressing when viewing a circuit 2016-10-31 11:36:05 -04:00
Jeremy Stretch
d97dd266b7 Cleaned up message strings 2016-10-31 11:16:30 -04:00
Jeremy Stretch
df9a6a0c53 Standardized device component deletion views to use ObjectDeleteView() 2016-10-28 17:00:41 -04:00
Jeremy Stretch
fd38daf0c5 Standardized device component edit views to use ObjectEditView() 2016-10-28 16:14:23 -04:00
Jeremy Stretch
28b4f6b8fd #181: Added ExpandableIPAddressField 2016-10-28 15:12:53 -04:00
Jeremy Stretch
2db50dd4a7 Closes #191: Support for racks numbered top-to-bottom 2016-10-28 11:30:40 -04:00
Florian Klink
5cd9c11169 gitignore static folder, concretize configuration.py location
this adds the netbox/static folder to the gitignore file, and further
specifies the path from where we'd like to ignore net netbox configuration.py.
2016-10-28 12:28:13 +02:00
Jeremy Stretch
f8f5d6876b Merge pull request #634 from jsenecal/patch-2
Fixed "Power Port" column name
2016-10-24 17:28:59 -04:00
Jonathan Senecal
198674f368 Fixed "Power Port" column name 2016-10-24 17:22:01 -04:00
Jeremy Stretch
e22eafc4a7 Closes #211: Allow device assignment and removal from IP address view 2016-10-24 15:07:11 -04:00
Jeremy Stretch
f44a322df5 Closes #630: Added a custom 404 page 2016-10-24 13:53:58 -04:00
Jeremy Stretch
fc2ac8a02b Attributed all model ValidationErrors to specific fields (where appropriate) 2016-10-21 15:39:13 -04:00
Jeremy Stretch
13243785f1 Closes #87: Added status field to IP addresses 2016-10-21 12:34:02 -04:00
Jeremy Stretch
35c207e936 Merge pull request #627 from kryskool/patch-1
Fix path to find configuration.py on migration
2016-10-20 13:44:20 -04:00
Christophe CHAUVET
998608111f Fix path to find configuration.py on migration 2016-10-20 09:55:03 +02:00
Jeremy Stretch
6018700421 Post-release version bump 2016-10-19 16:27:46 -04:00
Jeremy Stretch
c171547037 Merge pull request #625 from digitalocean/develop
Release v1.6.3
2016-10-19 16:25:50 -04:00
Jeremy Stretch
493b7d594d Release v1.6.3 2016-10-19 16:21:01 -04:00
Jeremy Stretch
4d40c015e4 Added instance count to DeviceType view 2016-10-19 15:56:15 -04:00
Jeremy Stretch
4405bc4182 Closes #608: Add "toggle all" button to device and device type components 2016-10-19 15:45:26 -04:00
Jeremy Stretch
54a0639a6e Merge pull request #623 from jsenecal/patch-1
Removed superfluous "is" in error message
2016-10-19 14:51:08 -04:00
Jonathan Senecal
334b286ebf Removed superfluous "is" in error message 2016-10-19 14:22:34 -04:00
Jeremy Stretch
c09cb5df3d #353: Added bulk editing for InterfaceTemplates 2016-10-19 12:15:54 -04:00
Jeremy Stretch
0da3661ff0 #353: Allow bulk editing of interfaces 2016-10-14 16:38:46 -04:00
Jeremy Stretch
5a4ccbc066 Fixes #616: Correct display of custom URL fields 2016-10-14 11:08:09 -04:00
Jeremy Stretch
49cbdc22da Fixes #615: Account for BASE_PATH in static URLs and during login 2016-10-13 16:27:09 -04:00
Jeremy Stretch
579ed0a985 Redirect user to previous page after logging in 2016-10-13 16:12:27 -04:00
Jeremy Stretch
464797858f Fixes #604: Correct display of unnamed devices in form selection fields 2016-10-13 15:21:36 -04:00
Jeremy Stretch
0ff46bf5d0 Fixes #611: Power/console/interface connection import: status field should be case-insensitive 2016-10-13 12:18:32 -04:00
Jeremy Stretch
330abe5a2d Fixes #602: Correct display of custom integer fields with value of 0 or 1 2016-10-05 15:29:16 -04:00
Jeremy Stretch
73945899fe Fixes #527: Support for nullifying custom fields during bulk editing 2016-10-05 15:17:17 -04:00
Jeremy Stretch
8227a9ff9c Merge pull request #592 from lf-/patch-2
Allow multiple ALLOWED_HOSTS on docker
2016-10-04 15:01:31 -04:00
Jeremy Stretch
f1c70cd896 Fixes #591: Correct display of device type component creation buttons 2016-10-04 14:46:27 -04:00
Jeremy Stretch
7055292803 Merge pull request #596 from digitalocean/bugfix-591
Fixes #591: Correct display of device type component creation buttons
2016-10-04 10:22:04 -04:00
Jeremy Stretch
3503c77699 Fixes #591: Correct display of component creation buttons in device type view 2016-10-04 10:17:56 -04:00
lf
b68c64041e Allow multiple ALLOWED_HOSTS on docker
Change ALLOWED_HOSTS to be a space delimited list.
2016-10-02 21:01:29 -06:00
Jeremy Stretch
36066068d4 #527: Initial work to allow nullifying fields during bulk edit 2016-09-30 16:17:41 -04:00
Jeremy Stretch
8ed174e7af Post-release version bump 2016-09-30 11:26:08 -04:00
Jeremy Stretch
7336fdf162 Merge pull request #587 from digitalocean/develop
Release v1.6.2
2016-09-30 11:24:16 -04:00
Jeremy Stretch
b5a7dd7d6d Reformatted URLs list to make pep8 happy 2016-09-30 11:19:50 -04:00
Jeremy Stretch
35918ae966 Release v1.6.2 2016-09-30 11:06:31 -04:00
Jeremy Stretch
ce01bb59a3 Fixed tests 2016-09-29 16:56:30 -04:00
Jeremy Stretch
18a5a966e3 Fixes #212: Tweak APISelect widget to inject BASE_PATH in API URL 2016-09-29 16:41:02 -04:00
Jeremy Stretch
833499ffe8 #212: Introduced BASE_PATH configuration setting 2016-09-29 16:32:16 -04:00
Jeremy Stretch
5b7f350ded Added headers to all bulk edit tables 2016-09-28 17:20:16 -04:00
Jeremy Stretch
d5fc0e9ce7 Closes #345: Bulk edit: allow user to select all objects on page or all matching query 2016-09-28 16:56:17 -04:00
Jeremy Stretch
c6592faeb2 Fixes #466: Validate available free space for all instances when increasing the U height of a device type 2016-09-28 14:19:52 -04:00
Jeremy Stretch
dec00cdb55 Closes #481: Require interface creation before trying to assign an IP to a device 2016-09-28 12:24:33 -04:00
Jeremy Stretch
30c7c2d359 Closes #475: Display add buttons at top and bottom of all device/device type panels 2016-09-28 12:06:00 -04:00
Jeremy Stretch
118bb5ea73 Fixed DCIM API test 2016-09-28 10:02:18 -04:00
Jeremy Stretch
35b3d8e33a Fixes #581: Corrected initialization of custom boolean and select fields 2016-09-28 09:58:59 -04:00
Jeremy Stretch
187a6dee17 Closes #579: Add a description field to ExportTemplate 2016-09-27 16:31:18 -04:00
Jeremy Stretch
0900a6bf49 Added subdevice_role to DeviceTypeSerializer 2016-09-27 16:04:14 -04:00
Jeremy Stretch
6cba2e92f2 Merge pull request #574 from rfdrake/mobile
viewport change for mobile
2016-09-27 13:58:57 -04:00
Jeremy Stretch
796b131f73 Fixes #577: Correct initialization of custom boolean fields 2016-09-27 13:42:10 -04:00
Jeremy Stretch
bdb8d62cef Closes #575: Allow all valid URL schemes in custom fields 2016-09-27 11:42:20 -04:00
Jeremy Stretch
d049c1c244 Fixes #576: Delete all relevant CustomFieldValues ehen deleting a CustomFieldChoice 2016-09-27 10:51:33 -04:00
Robert Drake
45432a6f29 viewport change for mobile 2016-09-26 23:43:05 -04:00
Jeremy Stretch
a803bd8033 Simplified web server installation doc 2016-09-26 14:21:10 -04:00
Jeremy Stretch
0001bbc966 #447: Correcting CentOS installation docs 2016-09-26 12:50:44 -04:00
Jeremy Stretch
1ebba3ee26 Merge pull request #529 from chagara/develop
Added centos/rhel installation
2016-09-26 12:05:19 -04:00
Jeremy Stretch
fde24258e3 Fixes #571: Correct rack group filter on device list 2016-09-22 10:06:59 -04:00
Jeremy Stretch
59c6d5b1ec Fixed typo 2016-09-21 11:58:04 -04:00
Jeremy Stretch
33694030b7 Tweaked ExportTemplate admin display 2016-09-21 11:57:05 -04:00
Jeremy Stretch
f8f973dac2 Post-release version bump 2016-09-21 11:45:12 -04:00
Jeremy Stretch
bffabef556 Merge pull request #570 from digitalocean/develop
Release v1.6.1-r1
2016-09-21 11:44:45 -04:00
Jeremy Stretch
325d96dabb Quick fix for v1.6.1 related to #561 2016-09-21 11:43:22 -04:00
Jeremy Stretch
b7b1682f42 Added link to the mailing list 2016-09-21 11:02:19 -04:00
Jeremy Stretch
aa2612aeba Post-release version bump 2016-09-21 10:15:03 -04:00
Jeremy Stretch
b99704082b Merge pull request #569 from digitalocean/develop
Release v1.6.1
2016-09-21 10:14:40 -04:00
Jeremy Stretch
75d8852bf7 Release v1.6.1 2016-09-21 09:55:57 -04:00
Jeremy Stretch
0444ac7db9 Introduced NullableModelMultipleChoiceField to allow null filtering without causing introspection issues during database migrations 2016-09-20 15:48:58 -04:00
Jeremy Stretch
b2684aeefc status filter fields should not be required 2016-09-20 11:29:30 -04:00
Jeremy Stretch
6ccc6244dd Corrected PrefixFilterForm 2016-09-20 11:25:16 -04:00
Jeremy Stretch
e618bf40ec Reimplemented FilterChoiceField 2016-09-20 11:08:25 -04:00
Jeremy Stretch
e3f0a12313 PEP8 fix 2016-09-19 16:21:42 -04:00
Jeremy Stretch
687e68db69 Fixes #564: Display custom fields for all applicable objects 2016-09-19 16:13:02 -04:00
Jeremy Stretch
b10e29aaac Closes #561: Make custom fields accessible from within export templates 2016-09-19 16:11:37 -04:00
Jeremy Stretch
d0c92b4f8a Removed obsolete dependency 2016-09-19 10:32:38 -04:00
Jeremy Stretch
513408f16a Fixes #562: Fixed bulk interface creation 2016-09-19 10:31:40 -04:00
Jeremy Stretch
64326e7c9d Closes #552: Added a None filter option for custom select fields 2016-09-16 13:42:07 -04:00
Jeremy Stretch
ce9d853883 Closes #415: Added an expand/collapse toggle button to the prefix list 2016-09-16 11:50:02 -04:00
Jeremy Stretch
814a0e7344 Tweak to #493 2016-09-16 10:31:42 -04:00
Jeremy Stretch
2c7c0ce29d Merge pull request #493 from stianvi/csv_reader_unicode
Fixed csv reader to handle special characters
2016-09-16 10:30:27 -04:00
Jeremy Stretch
2015d08407 Merge pull request #555 from rfdrake/develop
Fix for Docker ldap
2016-09-16 09:59:43 -04:00
Jeremy Stretch
9dea5656ad Added 'none' options to filters for optional fields 2016-09-15 17:12:53 -04:00
Jeremy Stretch
daadf7a49b Fixes #557: Add 'global' choice to VRF filter for prefixes and IP addresses 2016-09-15 16:03:53 -04:00
Jeremy Stretch
2567412121 Fixes #531: Order prefixes by VRF assignment 2016-09-15 12:09:54 -04:00
Jeremy Stretch
5e4fce248c Fixes #558: Update slug field when name is populated without a key press 2016-09-15 11:36:45 -04:00
Jeremy Stretch
824d2d8205 Implemented FilterChoiceField and get_filter_choices() to reduce filter form boilerplate 2016-09-14 16:27:26 -04:00
Robert Drake
9718895ff9 add django-auth-ldap to Dockerfile 2016-09-14 13:32:54 -04:00
Robert Drake
9eec975800 change ldap.py to ldap_config.py 2016-09-14 13:32:54 -04:00
Jeremy Stretch
5601be87f7 Merge pull request #551 from digitalocean/docker-build-perms
Fix permissions on docker-build.sh
2016-09-13 13:15:57 -04:00
Zach Moody
440610836a fixes permissions on docker-build.sh 2016-09-13 11:27:04 -05:00
Jeremy Stretch
4fa536b940 Post-release version bump 2016-09-13 12:16:42 -04:00
Chagara
af519b93b7 added more changes for centos/rhel installation 2016-09-02 16:54:47 -04:00
Chagara
2213e3e0cf added centos/rhel installation on netbox.md postgresql.md and web-server.md 2016-09-02 16:38:58 -04:00
Stian Vikan
0708942ab8 Fixed csv reader to handle special characters 2016-08-19 12:09:40 +02:00
191 changed files with 4811 additions and 2999 deletions

3
.gitignore vendored
View File

@@ -1,5 +1,6 @@
*.pyc
configuration.py
/netbox/netbox/configuration.py
/netbox/static
.idea
/*.sh
!upgrade.sh

View File

@@ -43,8 +43,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,10 @@ WORKDIR /opt/netbox
ARG BRANCH=master
ARG URL=https://github.com/digitalocean/netbox.git
RUN git clone --depth 1 $URL -b $BRANCH . && \
pip install gunicorn==17.5 && pip install -r requirements.txt
apt-get update -qq && apt-get install -y libldap2-dev libsasl2-dev libssl-dev graphviz && \
pip install gunicorn==17.5 && \
pip install django-auth-ldap && \
pip install -r requirements.txt
ADD docker/docker-entrypoint.sh /docker-entrypoint.sh
ADD netbox/netbox/configuration.docker.py /opt/netbox/netbox/netbox/configuration.py

View File

@@ -6,7 +6,7 @@ NetBox runs as a web application atop the [Django](https://www.djangoproject.com
The complete documentation for Netbox can be found at [Read the Docs](http://netbox.readthedocs.io/en/latest/).
Questions? Comments? Please join us on IRC in **#netbox** on **irc.freenode.net**!
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**!
### Build Status

View File

@@ -26,6 +26,18 @@ BANNER_BOTTOM = BANNER_TOP
---
## BASE_PATH
Default: None
The base URL path to use when accessing NetBox. Do not include the scheme or domain name. For example, if installed at http://example.com/netbox/, set:
```
BASE_PATH = 'netbox/'
```
---
## DEBUG
Default: False

View File

@@ -35,6 +35,8 @@ Each export template is associated with a certain type of object. For instance,
Export templates are written in [Django's template language](https://docs.djangoproject.com/en/1.9/ref/templates/language/), which is very similar to Jinja2. The list of objects returned from the database is stored in the `queryset` variable. Typically, you'll want to iterate through this list using a for loop.
To access custom fields of an object within a template, use the `cf` attribute. For example, `{{ obj.cf.color }}` will return the value (if any) for a custom field named `color` on `obj`.
A MIME type and file extension can optionally be defined for each export template. The default MIME type is `text/plain`.
## Example
@@ -96,4 +98,4 @@ dist-switch\d
access-switch\d+,oob-switch\d+
```
Note that you can combine multiple regexes onto one line using commas. (Commas can only be used for separating regexes; they will not be processed as part of a regex.) The order in which regexes are listed on a line is significant: devices matching the first regex will be rendered first, and subsequent groups will be rendered to the right of those.
Note that you can combine multiple regexes onto one line using semicolons. The order in which regexes are listed on a line is significant: devices matching the first regex will be rendered first, and subsequent groups will be rendered to the right of those.

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

@@ -1,19 +1,16 @@
# Installation
NetBox requires following system dependencies:
* python2.7
* python-dev
* python-pip
* libxml2-dev
* libxslt1-dev
* libffi-dev
* graphviz
* libpq-dev
* libssl-dev
**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
```
# sudo 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
```
You may opt to install NetBox either from a numbered release or by cloning the master branch of its repository on GitHub.
@@ -22,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/
@@ -34,20 +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
```
# sudo 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.
@@ -62,21 +66,21 @@ 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.)
```
# sudo pip install -r requirements.txt
```no-highlight
# pip install -r requirements.txt
```
# Configuration
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
```
Open `configuration.py` with your preferred editor and set the following variables:
* ALLOWED_HOSTS
* DATABASE
* SECRET_KEY
@@ -87,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']
```
@@ -97,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
@@ -120,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:
@@ -139,18 +143,18 @@ 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
Password:
Password (again):
Password:
Password (again):
Superuser created successfully.
```
# Collect Static Files
```
```no-highlight
# ./manage.py collectstatic
You have requested to collect static files at the destination
@@ -171,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)
```
@@ -180,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...
@@ -191,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,25 +1,41 @@
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
The following packages are needed to install PostgreSQL with Python support:
**Debian/Ubuntu**
* postgresql
* libpq-dev
* python-psycopg2
```
# sudo apt-get install -y postgresql libpq-dev python-psycopg2
```no-highlight
# apt-get install -y postgresql libpq-dev python-psycopg2
```
# Configuration
**CentOS/RHEL**
```no-highlight
# yum install -y postgresql postgresql-server postgresql-devel python-psycopg2
# postgresql-setup initdb
```
CentOS users should modify the PostgreSQL configuration to accept password-based authentication by replacing `ident` with `md5` for all host entries within `/var/lib/pgsql/data/pg_hba.conf`. For example:
```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
```
# Database Creation
At a minimum, we need to create a database for NetBox and assign it a username and password for authentication. This is done with the following commands.
!!! danger
DO NOT USE THE PASSWORD FROM THE EXAMPLE.
```
```no-highlight
# sudo -u postgres psql
psql (9.3.13)
Type "help" for help.
@@ -35,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:
```
# cp /opt/netbox-X.Y.Z/configuration.py /opt/netbox/configuration.py
```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

@@ -1,22 +1,25 @@
# Web Server Installation
We'll set up a simple WSGI front end using [gunicorn](http://gunicorn.org/) for the purposes of this guide. For web servers, we provide example configurations for both [nginx](https://www.nginx.com/resources/wiki/) and [Apache](http://httpd.apache.org/docs/2.4). (You are of course free to use whichever combination of HTTP and WSGI services you'd like.) We'll also use [supervisord](http://supervisord.org/) to enable service persistence.
We'll set up a simple WSGI front end using [gunicorn](http://gunicorn.org/) for the purposes of this guide. For web servers, we provide example configurations for both [nginx](https://www.nginx.com/resources/wiki/) and [Apache](http://httpd.apache.org/docs/2.4). (You are of course free to use whichever combination of HTTP and WSGI services you'd like.) We'll also use [supervisord](http://supervisord.org/) to enable service persistence.
```
# sudo apt-get install -y gunicorn supervisor
!!! 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
```
## Option A: nginx
The following will serve as a minimal nginx configuration. Be sure to modify your server name and installation path appropriately.
```
# sudo apt-get install -y nginx
```no-highlight
# apt-get install -y nginx
```
Once nginx is installed, proceed with the following configuration:
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;
@@ -38,32 +41,31 @@ server {
}
```
Save this configuration to `/etc/nginx/sites-available/netbox`. Then, delete `/etc/nginx/sites-enabled/default` and create a symlink in the `sites-enabled` directory to the configuration file you just created.
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
# ln -s /etc/nginx/sites-available/netbox
```
Restart the nginx service to use the new configuration.
```
```no-highlight
# service nginx restart
* Restarting nginx nginx
```
To enable SSL, consider this guide on [securing nginx with Let's Encrypt](https://www.digitalocean.com/community/tutorials/how-to-secure-nginx-with-let-s-encrypt-on-ubuntu-14-04).
## Option B: Apache
```
# sudo apt-get install -y apache2
```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
@@ -88,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
@@ -99,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.
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'
@@ -111,18 +113,18 @@ 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/
user = www-data
```
Finally, restart the supervisor service to detect and run the gunicorn service:
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,24 @@ 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', '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

@@ -5,6 +5,8 @@ from django.db.models import Q
from dcim.models import Site
from extras.filters import CustomFieldFilterSet
from tenancy.models import Tenant
from utilities.filters import NullableModelMultipleChoiceFilter
from .models import Provider, Circuit, CircuitType
@@ -14,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)',
@@ -27,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(
@@ -48,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)',
@@ -59,29 +61,29 @@ 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)',
)
tenant_id = django_filters.ModelMultipleChoiceFilter(
tenant_id = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
label='Tenant (ID)',
)
tenant = django_filters.ModelMultipleChoiceFilter(
tenant = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
to_field_name='slug',
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)',
@@ -89,12 +91,11 @@ 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(comments__icontains=value)
)

View File

@@ -3,13 +3,13 @@ from django.db.models import Count
from dcim.models import Site, Device, Interface, Rack, IFACE_FF_VIRTUAL
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
from tenancy.forms import bulkedit_tenant_choices
from tenancy.models import Tenant
from utilities.forms import (
APISelect, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, Livesearch, SmallTextarea, SlugField,
APISelect, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, FilterChoiceField, Livesearch, SmallTextarea,
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,25 +54,22 @@ 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)
def provider_site_choices():
site_choices = Site.objects.all()
return [(s.slug, s.name) for s in site_choices]
class Meta:
nullable_fields = ['asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments']
class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Provider
site = forms.MultipleChoiceField(required=False, choices=provider_site_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
site = FilterChoiceField(queryset=Site.objects.all(), to_field_name='slug')
#
# Circuit types
#
class CircuitTypeForm(forms.ModelForm, BootstrapMixin):
class CircuitTypeForm(BootstrapMixin, forms.ModelForm):
slug = SlugField()
class Meta:
@@ -85,41 +82,96 @@ class CircuitTypeForm(forms.ModelForm, BootstrapMixin):
#
class CircuitForm(BootstrapMixin, CustomFieldForm):
comments = CommentField()
class Meta:
model = Circuit
fields = ['cid', 'type', 'provider', 'tenant', 'install_date', 'commit_rate', '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']
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)')
comments = CommentField(widget=SmallTextarea)
class Meta:
nullable_fields = ['tenant', '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('circuit_terminations')),
to_field_name='slug')
#
# Circuit terminations
#
class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.Select(attrs={'filter-for': 'rack'}))
rack = forms.ModelChoiceField(queryset=Rack.objects.all(), required=False, label='Rack',
widget=APISelect(api_url='/api/dcim/racks/?site_id={{site}}',
attrs={'filter-for': 'device'}))
device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, label='Device',
widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}',
attrs={'filter-for': 'interface'}))
display_field='display_name', attrs={'filter-for': 'interface'}))
livesearch = forms.CharField(required=False, label='Device', widget=Livesearch(
query_key='q', query_url='dcim-api:device_list', field_to_update='device')
)
interface = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Interface',
widget=APISelect(api_url='/api/dcim/devices/{{device}}/interfaces/?type=physical',
disabled_indicator='is_connected'))
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
@@ -143,11 +195,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 = []
@@ -157,64 +211,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.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
port_speed = forms.IntegerField(required=False, label='Port speed (Kbps)')
commit_rate = forms.IntegerField(required=False, label='Commit rate (Kbps)')
comments = CommentField()
def circuit_type_choices():
type_choices = CircuitType.objects.annotate(circuit_count=Count('circuits'))
return [(t.slug, u'{} ({})'.format(t.name, t.circuit_count)) for t in type_choices]
def circuit_provider_choices():
provider_choices = Provider.objects.annotate(circuit_count=Count('circuits'))
return [(p.slug, u'{} ({})'.format(p.name, p.circuit_count)) for p in provider_choices]
def circuit_tenant_choices():
tenant_choices = Tenant.objects.annotate(circuit_count=Count('circuits'))
return [(t.slug, u'{} ({})'.format(t.name, t.circuit_count)) for t in tenant_choices]
def circuit_site_choices():
site_choices = Site.objects.annotate(circuit_count=Count('circuits'))
return [(s.slug, u'{} ({})'.format(s.name, s.circuit_count)) for s in site_choices]
class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Circuit
type = forms.MultipleChoiceField(required=False, choices=circuit_type_choices)
provider = forms.MultipleChoiceField(required=False, choices=circuit_provider_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
tenant = forms.MultipleChoiceField(required=False, choices=circuit_tenant_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
site = forms.MultipleChoiceField(required=False, choices=circuit_site_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))

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

@@ -3,12 +3,35 @@ 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.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
@@ -71,15 +94,8 @@ 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)')
comments = models.TextField(blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
@@ -99,42 +115,61 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
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,
])
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_parent_url(self):
return self.circuit.get_absolute_url()
def get_peer_termination(self):
peer_side = 'Z' if self.term_side == 'A' else 'A'
try:
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,13 @@ 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')
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')])
commit_rate = tables.Column(accessor=Accessor('commit_rate_human'), order_by=Accessor('commit_rate'),
verbose_name='Commit Rate')
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', 'commit_rate')

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,18 @@
from django.contrib import messages
from django.contrib.auth.decorators import permission_required
from django.contrib.auth.mixins import PermissionRequiredMixin
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 +31,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,7 +46,7 @@ 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):
@@ -88,8 +92,8 @@ 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'
obj_list_url = 'circuits:circuittype_list'
use_obj_view = False
class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -103,7 +107,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 +118,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,9 +132,9 @@ 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):
@@ -155,3 +163,71 @@ 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:
circuit = get_object_or_404(Circuit, pk=kwargs['circuit'])
obj.circuit = circuit
return obj
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

@@ -5,6 +5,7 @@ from dcim.models import (
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceType,
DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet,
PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackRole, RACK_FACE_FRONT, RACK_FACE_REAR, Site,
SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT,
)
from extras.api.serializers import CustomFieldSerializer
from tenancy.api.serializers import TenantNestedSerializer
@@ -19,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):
@@ -78,7 +80,7 @@ class RackSerializer(CustomFieldSerializer, serializers.ModelSerializer):
class Meta:
model = Rack
fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width',
'u_height', 'comments', 'custom_fields']
'u_height', 'desc_units', 'comments', 'custom_fields']
class RackNestedSerializer(RackSerializer):
@@ -93,7 +95,7 @@ class RackDetailSerializer(RackSerializer):
class Meta(RackSerializer.Meta):
fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width',
'u_height', 'comments', 'custom_fields', 'front_units', 'rear_units']
'u_height', 'desc_units', 'comments', 'custom_fields', 'front_units', 'rear_units']
def get_front_units(self, obj):
units = obj.get_rack_units(face=RACK_FACE_FRONT)
@@ -129,13 +131,21 @@ 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']
'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'comments', 'custom_fields']
def get_subdevice_role(self, obj):
return {
SUBDEVICE_ROLE_PARENT: 'parent',
SUBDEVICE_ROLE_CHILD: 'child',
None: None,
}[obj.subdevice_role]
class DeviceTypeNestedSerializer(DeviceTypeSerializer):
@@ -188,8 +198,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']
'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']
#
@@ -372,7 +383,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,11 @@ class RackUnitListView(APIView):
rack = get_object_or_404(Rack, pk=pk)
face = request.GET.get('face', 0)
elevation = rack.get_rack_units(face)
try:
exclude = int(request.GET.get('exclude', None))
except ValueError:
exclude = None
elevation = rack.get_rack_units(face, exclude)
# Serialize Devices within the rack elevation
for u in elevation:
@@ -152,20 +156,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 +455,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 +488,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,9 +1,11 @@
import django_filters
from netaddr.core import AddrFormatError
from django.db.models import Q
from extras.filters import CustomFieldFilterSet
from tenancy.models import Tenant
from utilities.filters import NullableModelMultipleChoiceFilter
from .models import (
ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, Interface, InterfaceConnection, Manufacturer,
Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackRole, Site,
@@ -15,12 +17,12 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
action='search',
label='Search',
)
tenant_id = django_filters.ModelMultipleChoiceFilter(
tenant_id = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
label='Tenant (ID)',
)
tenant = django_filters.ModelMultipleChoiceFilter(
tenant = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
to_field_name='slug',
@@ -48,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)',
@@ -56,7 +58,6 @@ class RackGroupFilter(django_filters.FilterSet):
class Meta:
model = RackGroup
fields = ['site_id', 'site']
class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
@@ -70,39 +71,39 @@ 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)',
)
group_id = django_filters.ModelMultipleChoiceFilter(
group_id = NullableModelMultipleChoiceFilter(
name='group',
queryset=RackGroup.objects.all(),
label='Group (ID)',
)
group = django_filters.ModelMultipleChoiceFilter(
group = NullableModelMultipleChoiceFilter(
name='group',
queryset=RackGroup.objects.all(),
to_field_name='slug',
label='Group',
)
tenant_id = django_filters.ModelMultipleChoiceFilter(
tenant_id = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
label='Tenant (ID)',
)
tenant = django_filters.ModelMultipleChoiceFilter(
tenant = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
to_field_name='slug',
label='Tenant (slug)',
)
role_id = django_filters.ModelMultipleChoiceFilter(
role_id = NullableModelMultipleChoiceFilter(
name='role',
queryset=RackRole.objects.all(),
label='Role (ID)',
)
role = django_filters.ModelMultipleChoiceFilter(
role = NullableModelMultipleChoiceFilter(
name='role',
queryset=RackRole.objects.all(),
to_field_name='slug',
@@ -111,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(
@@ -121,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)',
@@ -136,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):
@@ -145,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)',
@@ -172,17 +189,17 @@ 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)',
)
tenant_id = django_filters.ModelMultipleChoiceFilter(
tenant_id = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
label='Tenant (ID)',
)
tenant = django_filters.ModelMultipleChoiceFilter(
tenant = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
to_field_name='slug',
@@ -199,23 +216,23 @@ 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)',
)
platform_id = django_filters.ModelMultipleChoiceFilter(
platform_id = NullableModelMultipleChoiceFilter(
name='platform',
queryset=Platform.objects.all(),
label='Platform (ID)',
)
platform = django_filters.ModelMultipleChoiceFilter(
platform = NullableModelMultipleChoiceFilter(
name='platform',
queryset=Platform.objects.all(),
to_field_name='slug',
@@ -240,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(
@@ -253,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(
@@ -269,7 +293,7 @@ class ConsolePortFilter(django_filters.FilterSet):
class Meta:
model = ConsolePort
fields = ['device_id', 'device', 'name']
fields = ['name']
class ConsoleServerPortFilter(django_filters.FilterSet):
@@ -287,7 +311,7 @@ class ConsoleServerPortFilter(django_filters.FilterSet):
class Meta:
model = ConsoleServerPort
fields = ['device_id', 'device', 'name']
fields = ['name']
class PowerPortFilter(django_filters.FilterSet):
@@ -305,7 +329,7 @@ class PowerPortFilter(django_filters.FilterSet):
class Meta:
model = PowerPort
fields = ['device_id', 'device', 'name']
fields = ['name']
class PowerOutletFilter(django_filters.FilterSet):
@@ -323,7 +347,7 @@ class PowerOutletFilter(django_filters.FilterSet):
class Meta:
model = PowerOutlet
fields = ['device_id', 'device', 'name']
fields = ['name']
class InterfaceFilter(django_filters.FilterSet):
@@ -341,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

@@ -1,23 +1,25 @@
import re
from django import forms
from django.core.exceptions import ValidationError
from django.db.models import Count, Q
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
from ipam.models import IPAddress
from tenancy.forms import bulkedit_tenant_choices
from tenancy.models import Tenant
from utilities.forms import (
APISelect, add_blank_choice, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, ExpandableNameField,
FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField,
APISelect, add_blank_choice, BootstrapMixin, BulkEditForm, BulkImportForm, CommentField, CSVDataField,
ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea,
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_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, InterfaceConnection, InterfaceTemplate, Manufacturer, Module,
Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES,
Rack, RackGroup, RackRole, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD
)
@@ -42,37 +44,12 @@ def get_device_by_name_or_pk(name):
return device
def bulkedit_platform_choices():
choices = [
(None, '---------'),
(0, 'None'),
]
choices += [(p.pk, p.name) for p in Platform.objects.all()]
return choices
def bulkedit_rackgroup_choices():
def validate_connection_status(value):
"""
Include an option to remove the currently assigned group from a rack.
Custom validator for connection statuses. value must be either "planned" or "connected" (case-insensitive).
"""
choices = [
(None, '---------'),
(0, 'None'),
]
choices += [(r.pk, r) for r in RackGroup.objects.all()]
return choices
def bulkedit_rackrole_choices():
"""
Include an option to remove the currently assigned role from a rack.
"""
choices = [
(None, '---------'),
(0, 'None'),
]
choices += [(r.pk, r.name) for r in RackRole.objects.all()]
return choices
if value.lower() not in ['planned', 'connected']:
raise ValidationError('Invalid connection status ({}); must be either "planned" or "connected".'.format(value))
#
@@ -85,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}),
@@ -105,34 +83,33 @@ 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.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
asn = forms.IntegerField(min_value=1, max_value=4294967295, required=False, label='ASN')
def site_tenant_choices():
tenant_choices = Tenant.objects.annotate(site_count=Count('sites'))
return [(t.slug, u'{} ({})'.format(t.name, t.site_count)) for t in tenant_choices]
class Meta:
nullable_fields = ['tenant', 'asn']
class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Site
tenant = forms.MultipleChoiceField(required=False, choices=site_tenant_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('sites')), to_field_name='slug',
null_option=(0, 'None'))
#
# Rack groups
#
class RackGroupForm(forms.ModelForm, BootstrapMixin):
class RackGroupForm(BootstrapMixin, forms.ModelForm):
slug = SlugField()
class Meta:
@@ -140,21 +117,15 @@ class RackGroupForm(forms.ModelForm, BootstrapMixin):
fields = ['site', 'name', 'slug']
def rackgroup_site_choices():
site_choices = Site.objects.annotate(rack_count=Count('rack_groups'))
return [(s.slug, u'{} ({})'.format(s.name, s.rack_count)) for s in site_choices]
class RackGroupFilterForm(forms.Form, BootstrapMixin):
site = forms.MultipleChoiceField(required=False, choices=rackgroup_site_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
class RackGroupFilterForm(BootstrapMixin, forms.Form):
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('rack_groups')), to_field_name='slug')
#
# Rack roles
#
class RackRoleForm(forms.ModelForm, BootstrapMixin):
class RackRoleForm(BootstrapMixin, forms.ModelForm):
slug = SlugField()
class Meta:
@@ -174,7 +145,8 @@ class RackForm(BootstrapMixin, CustomFieldForm):
class Meta:
model = Rack
fields = ['site', 'group', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height', 'comments']
fields = ['site', 'group', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height', 'desc_units',
'comments']
help_texts = {
'site': "The site at which the rack exists",
'name': "Organizational rack name",
@@ -210,7 +182,8 @@ class RackFromCSVForm(forms.ModelForm):
class Meta:
model = Rack
fields = ['site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height']
fields = ['site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height',
'desc_units']
def clean(self):
@@ -238,59 +211,41 @@ class RackFromCSVForm(forms.ModelForm):
))
class RackImportForm(BulkImportForm, BootstrapMixin):
class RackImportForm(BootstrapMixin, BulkImportForm):
csv = CSVDataField(csv_form=RackFromCSVForm)
class RackBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Rack.objects.all(), widget=forms.MultipleHiddenInput)
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site')
group = forms.TypedChoiceField(choices=bulkedit_rackgroup_choices, coerce=int, required=False, label='Group')
tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
role = forms.TypedChoiceField(choices=bulkedit_rackrole_choices, coerce=int, required=False, label='Role')
group = forms.ModelChoiceField(queryset=RackGroup.objects.all(), required=False, label='Group')
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
role = forms.ModelChoiceField(queryset=RackRole.objects.all(), required=False)
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)
def rack_site_choices():
site_choices = Site.objects.annotate(rack_count=Count('racks'))
return [(s.slug, u'{} ({})'.format(s.name, s.rack_count)) for s in site_choices]
def rack_group_choices():
group_choices = RackGroup.objects.select_related('site').annotate(rack_count=Count('racks'))
return [(g.pk, u'{} ({})'.format(g, g.rack_count)) for g in group_choices]
def rack_tenant_choices():
tenant_choices = Tenant.objects.annotate(rack_count=Count('racks'))
return [(t.slug, u'{} ({})'.format(t.name, t.rack_count)) for t in tenant_choices]
def rack_role_choices():
role_choices = RackRole.objects.annotate(rack_count=Count('racks'))
return [(r.slug, u'{} ({})'.format(r.name, r.rack_count)) for r in role_choices]
class Meta:
nullable_fields = ['group', 'tenant', 'role', 'comments']
class RackFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Rack
site = forms.MultipleChoiceField(required=False, choices=rack_site_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
group_id = forms.MultipleChoiceField(required=False, choices=rack_group_choices, label='Rack Group',
widget=forms.SelectMultiple(attrs={'size': 8}))
tenant = forms.MultipleChoiceField(required=False, choices=rack_tenant_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
role = forms.MultipleChoiceField(required=False, choices=rack_role_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('racks')), to_field_name='slug')
group_id = FilterChoiceField(queryset=RackGroup.objects.select_related('site')
.annotate(filter_count=Count('racks')), label='Rack group', null_option=(0, 'None'))
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('racks')), to_field_name='slug',
null_option=(0, 'None'))
role = FilterChoiceField(queryset=RackRole.objects.annotate(filter_count=Count('racks')), to_field_name='slug',
null_option=(0, 'None'))
#
# Manufacturers
#
class ManufacturerForm(forms.ModelForm, BootstrapMixin):
class ManufacturerForm(BootstrapMixin, forms.ModelForm):
slug = SlugField()
class Meta:
@@ -302,88 +257,133 @@ 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', 'comments']
class DeviceTypeBulkEditForm(forms.Form, 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)
def devicetype_manufacturer_choices():
manufacturer_choices = Manufacturer.objects.annotate(devicetype_count=Count('device_types'))
return [(m.slug, u'{} ({})'.format(m.name, m.devicetype_count)) for m in manufacturer_choices]
class Meta:
nullable_fields = []
class DeviceTypeFilterForm(forms.Form, BootstrapMixin):
manufacturer = forms.MultipleChoiceField(required=False, choices=devicetype_manufacturer_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
class DeviceTypeFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = DeviceType
manufacturer = FilterChoiceField(queryset=Manufacturer.objects.annotate(filter_count=Count('device_types')),
to_field_name='slug')
#
# 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 DeviceBayTemplateForm(forms.ModelForm, BootstrapMixin):
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):
pk = forms.ModelMultipleChoiceField(queryset=InterfaceTemplate.objects.all(), widget=forms.MultipleHiddenInput)
form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False)
class Meta:
nullable_fields = []
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:
@@ -395,7 +395,7 @@ class DeviceRoleForm(forms.ModelForm, BootstrapMixin):
# Platforms
#
class PlatformForm(forms.ModelForm, BootstrapMixin):
class PlatformForm(BootstrapMixin, forms.ModelForm):
slug = SlugField()
class Meta:
@@ -415,7 +415,7 @@ class DeviceForm(BootstrapMixin, CustomFieldForm):
attrs={'filter-for': 'position'}
))
position = forms.TypedChoiceField(required=False, empty_value=None,
help_text="For multi-U devices, this is the lowest occupied rack unit.",
help_text="The lowest-numbered unit occupied by the device",
widget=APISelect(api_url='/api/dcim/racks/{{rack}}/rack-units/?face={{face}}',
disabled_indicator='device'))
manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(),
@@ -459,6 +459,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
@@ -608,11 +612,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)
@@ -620,64 +624,52 @@ class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput)
device_type = forms.ModelChoiceField(queryset=DeviceType.objects.all(), required=False, label='Type')
device_role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), required=False, label='Role')
tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
platform = forms.TypedChoiceField(choices=bulkedit_platform_choices, coerce=int, required=False,
label='Platform')
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
platform = forms.ModelChoiceField(queryset=Platform.objects.all(), required=False)
status = forms.ChoiceField(choices=FORM_STATUS_CHOICES, required=False, initial='', label='Status')
serial = forms.CharField(max_length=50, required=False, label='Serial Number')
def device_site_choices():
site_choices = Site.objects.annotate(device_count=Count('racks__devices'))
return [(s.slug, u'{} ({})'.format(s.name, s.device_count)) for s in site_choices]
def device_rack_group_choices():
group_choices = RackGroup.objects.select_related('site').annotate(device_count=Count('racks__devices'))
return [(g.pk, u'{} ({})'.format(g, g.device_count)) for g in group_choices]
def device_role_choices():
role_choices = DeviceRole.objects.annotate(device_count=Count('devices'))
return [(r.slug, u'{} ({})'.format(r.name, r.device_count)) for r in role_choices]
def device_tenant_choices():
tenant_choices = Tenant.objects.annotate(device_count=Count('devices'))
return [(t.slug, u'{} ({})'.format(t.name, t.device_count)) for t in tenant_choices]
def device_type_choices():
type_choices = DeviceType.objects.select_related('manufacturer').annotate(device_count=Count('instances'))
return [(t.pk, u'{} ({})'.format(t, t.device_count)) for t in type_choices]
def device_platform_choices():
platform_choices = Platform.objects.annotate(device_count=Count('devices'))
return [(p.slug, u'{} ({})'.format(p.name, p.device_count)) for p in platform_choices]
class Meta:
nullable_fields = ['tenant', 'platform']
class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Device
site = forms.MultipleChoiceField(required=False, choices=device_site_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
rack_group_id = forms.MultipleChoiceField(required=False, choices=device_rack_group_choices, label='Rack Group',
widget=forms.SelectMultiple(attrs={'size': 8}))
role = forms.MultipleChoiceField(required=False, choices=device_role_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
tenant = forms.MultipleChoiceField(required=False, choices=device_tenant_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
device_type_id = forms.MultipleChoiceField(required=False, choices=device_type_choices, label='Type',
widget=forms.SelectMultiple(attrs={'size': 8}))
platform = forms.MultipleChoiceField(required=False, choices=device_platform_choices)
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('racks__devices')), to_field_name='slug')
rack_group_id = FilterChoiceField(queryset=RackGroup.objects.annotate(filter_count=Count('racks__devices')),
label='Rack Group')
role = FilterChoiceField(queryset=DeviceRole.objects.annotate(filter_count=Count('devices')), to_field_name='slug')
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('devices')), to_field_name='slug',
null_option=(0, 'None'))
device_type_id = FilterChoiceField(queryset=DeviceType.objects.select_related('manufacturer')
.annotate(filter_count=Count('instances')), label='Type')
platform = FilterChoiceField(queryset=Platform.objects.annotate(filter_count=Count('devices')),
to_field_name='slug', null_option=(0, 'None'))
status = forms.NullBooleanField(required=False, widget=forms.Select(choices=FORM_STATUS_CHOICES))
mac_address = forms.CharField(required=False, label='MAC address')
#
# 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
@@ -687,7 +679,7 @@ class ConsolePortForm(forms.ModelForm, BootstrapMixin):
}
class ConsolePortCreateForm(forms.Form, BootstrapMixin):
class ConsolePortCreateForm(BootstrapMixin, forms.Form):
name_pattern = ExpandableNameField(label='Name')
@@ -699,7 +691,7 @@ class ConsoleConnectionCSVForm(forms.Form):
device = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Device not found'})
console_port = forms.CharField()
status = forms.ChoiceField(choices=[('planned', 'Planned'), ('connected', 'Connected')])
status = forms.CharField(validators=[validate_connection_status])
def clean(self):
@@ -728,7 +720,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):
@@ -758,11 +750,12 @@ 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,
widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}&is_console_server=True',
display_field='display_name',
attrs={'filter-for': 'cs_port'}))
livesearch = forms.CharField(required=False, label='Console Server', widget=Livesearch(
query_key='q', query_url='dcim-api:device_list', field_to_update='console_server')
@@ -811,7 +804,7 @@ class ConsolePortConnectionForm(forms.ModelForm, BootstrapMixin):
# Console server ports
#
class ConsoleServerPortForm(forms.ModelForm, BootstrapMixin):
class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = ConsoleServerPort
@@ -821,16 +814,16 @@ 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,
widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}',
attrs={'filter-for': 'port'}))
display_field='display_name', attrs={'filter-for': 'port'}))
livesearch = forms.CharField(required=False, label='Device', widget=Livesearch(
query_key='q', query_url='dcim-api:device_list', field_to_update='device')
)
@@ -873,7 +866,7 @@ class ConsoleServerPortConnectionForm(forms.Form, BootstrapMixin):
# Power ports
#
class PowerPortForm(forms.ModelForm, BootstrapMixin):
class PowerPortForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = PowerPort
@@ -883,7 +876,7 @@ class PowerPortForm(forms.ModelForm, BootstrapMixin):
}
class PowerPortCreateForm(forms.Form, BootstrapMixin):
class PowerPortCreateForm(BootstrapMixin, forms.Form):
name_pattern = ExpandableNameField(label='Name')
@@ -894,7 +887,7 @@ class PowerConnectionCSVForm(forms.Form):
device = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Device not found'})
power_port = forms.CharField()
status = forms.ChoiceField(choices=[('planned', 'Planned'), ('connected', 'Connected')])
status = forms.CharField(validators=[validate_connection_status])
def clean(self):
@@ -924,7 +917,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):
@@ -954,12 +947,12 @@ 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,
widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}&is_pdu=True',
attrs={'filter-for': 'power_outlet'}))
display_field='display_name', attrs={'filter-for': 'power_outlet'}))
livesearch = forms.CharField(required=False, label='PDU', widget=Livesearch(
query_key='q', query_url='dcim-api:device_list', field_to_update='pdu')
)
@@ -1007,7 +1000,7 @@ class PowerPortConnectionForm(forms.ModelForm, BootstrapMixin):
# Power outlets
#
class PowerOutletForm(forms.ModelForm, BootstrapMixin):
class PowerOutletForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = PowerOutlet
@@ -1017,16 +1010,16 @@ 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,
widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}',
attrs={'filter-for': 'port'}))
display_field='display_name', attrs={'filter-for': 'port'}))
livesearch = forms.CharField(required=False, label='Device', widget=Livesearch(
query_key='q', query_url='dcim-api:device_list', field_to_update='device')
)
@@ -1069,7 +1062,7 @@ class PowerOutletConnectionForm(forms.Form, BootstrapMixin):
# Interfaces
#
class InterfaceForm(forms.ModelForm, BootstrapMixin):
class InterfaceForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = Interface
@@ -1079,28 +1072,37 @@ class InterfaceForm(forms.ModelForm, BootstrapMixin):
}
class InterfaceCreateForm(forms.ModelForm, BootstrapMixin):
class InterfaceCreateForm(BootstrapMixin, forms.Form):
name_pattern = ExpandableNameField(label='Name')
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):
pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False)
description = forms.CharField(max_length=100, required=False)
class Meta:
model = Interface
fields = ['name_pattern', 'form_factor', 'mac_address', 'mgmt_only', 'description']
class InterfaceBulkCreateForm(InterfaceCreateForm, BootstrapMixin):
pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput)
nullable_fields = ['description']
#
# 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',
attrs={'filter-for': 'interface_b'}))
livesearch = forms.CharField(required=False, label='Device', widget=Livesearch(
query_key='q', query_url='dcim-api:device_list', field_to_update='device_b')
@@ -1111,21 +1113,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'])
@@ -1136,11 +1144,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 = [
@@ -1155,7 +1165,7 @@ class InterfaceConnectionCSVForm(forms.Form):
device_b = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Device B not found.'})
interface_b = forms.CharField()
status = forms.ChoiceField(choices=[('planned', 'Planned'), ('connected', 'Connected')])
status = forms.CharField(validators=[validate_connection_status])
def clean(self):
@@ -1190,7 +1200,7 @@ class InterfaceConnectionCSVForm(forms.Form):
pass
class InterfaceConnectionImportForm(BulkImportForm, BootstrapMixin):
class InterfaceConnectionImportForm(BootstrapMixin, BulkImportForm):
csv = CSVDataField(csv_form=InterfaceConnectionCSVForm)
def clean(self):
@@ -1230,7 +1240,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)
@@ -1240,7 +1250,7 @@ class InterfaceConnectionDeletionForm(forms.Form, BootstrapMixin):
# Device bays
#
class DeviceBayForm(forms.ModelForm, BootstrapMixin):
class DeviceBayForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = DeviceBay
@@ -1250,11 +1260,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.")
@@ -1275,15 +1285,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')
@@ -1291,15 +1301,12 @@ class InterfaceConnectionFilterForm(forms.Form, BootstrapMixin):
# IP addresses
#
class IPAddressForm(forms.ModelForm, BootstrapMixin):
class IPAddressForm(BootstrapMixin, CustomFieldForm):
set_as_primary = forms.BooleanField(label='Set as primary IP for device', required=False)
class Meta:
model = IPAddress
fields = ['address', 'vrf', 'interface', 'set_as_primary']
help_texts = {
'address': 'IPv4 or IPv6 address (with mask)'
}
fields = ['address', 'vrf', 'tenant', 'status', 'interface', 'description']
def __init__(self, device, *args, **kwargs):
@@ -1307,19 +1314,24 @@ class IPAddressForm(forms.ModelForm, BootstrapMixin):
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
#
# Interfaces
# Modules
#
class ModuleForm(forms.ModelForm, BootstrapMixin):
class ModuleForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = Module

View File

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10 on 2016-10-28 15:01
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0019_new_iface_form_factors'),
]
operations = [
migrations.AddField(
model_name='rack',
name='desc_units',
field=models.BooleanField(default=False, help_text=b'Units are numbered top-to-bottom', verbose_name=b'Descending units'),
),
]

View File

@@ -0,0 +1,31 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10 on 2016-10-31 18:47
from __future__ import unicode_literals
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0020_rack_desc_units'),
]
operations = [
migrations.AlterField(
model_name='device',
name='position',
field=models.PositiveSmallIntegerField(blank=True, help_text=b'The lowest-numbered unit occupied by the device', null=True, validators=[django.core.validators.MinValueValidator(1)], verbose_name=b'Position (U)'),
),
migrations.AlterField(
model_name='interface',
name='form_factor',
field=models.PositiveSmallIntegerField(choices=[[b'Virtual interfaces', [[0, b'Virtual']]], [b'Ethernet (fixed)', [[800, b'100BASE-TX (10/100ME)'], [1000, b'1000BASE-T (1GE)'], [1150, b'10GBASE-T (10GE)']]], [b'Ethernet (modular)', [[1050, b'GBIC (1GE)'], [1100, b'SFP (1GE)'], [1200, b'SFP+ (10GE)'], [1300, b'XFP (10GE)'], [1310, b'XENPAK (10GE)'], [1320, b'X2 (10GE)'], [1350, b'SFP28 (25GE)'], [1400, b'QSFP+ (40GE)'], [1500, b'CFP (100GE)'], [1600, b'QSFP28 (100GE)']]], [b'FibreChannel', [[3010, b'SFP (1GFC)'], [3020, b'SFP (2GFC)'], [3040, b'SFP (4GFC)'], [3080, b'SFP+ (8GFC)'], [3160, b'SFP+ (16GFC)']]], [b'Serial', [[4000, b'T1 (1.544 Mbps)'], [4010, b'E1 (2.048 Mbps)'], [4040, b'T3 (45 Mbps)'], [4050, b'E3 (34 Mbps)']]], [b'Stacking', [[5000, b'Cisco StackWise'], [5050, b'Cisco StackWise Plus'], [5100, b'Cisco FlexStack'], [5150, b'Cisco FlexStack Plus']]], [b'Other', [[32767, b'Other']]]], default=1200),
),
migrations.AlterField(
model_name='interfacetemplate',
name='form_factor',
field=models.PositiveSmallIntegerField(choices=[[b'Virtual interfaces', [[0, b'Virtual']]], [b'Ethernet (fixed)', [[800, b'100BASE-TX (10/100ME)'], [1000, b'1000BASE-T (1GE)'], [1150, b'10GBASE-T (10GE)']]], [b'Ethernet (modular)', [[1050, b'GBIC (1GE)'], [1100, b'SFP (1GE)'], [1200, b'SFP+ (10GE)'], [1300, b'XFP (10GE)'], [1310, b'XENPAK (10GE)'], [1320, b'X2 (10GE)'], [1350, b'SFP28 (25GE)'], [1400, b'QSFP+ (40GE)'], [1500, b'CFP (100GE)'], [1600, b'QSFP28 (100GE)']]], [b'FibreChannel', [[3010, b'SFP (1GFC)'], [3020, b'SFP (2GFC)'], [3040, b'SFP (4GFC)'], [3080, b'SFP+ (8GFC)'], [3160, b'SFP+ (16GFC)']]], [b'Serial', [[4000, b'T1 (1.544 Mbps)'], [4010, b'E1 (2.048 Mbps)'], [4040, b'T3 (45 Mbps)'], [4050, b'E3 (34 Mbps)']]], [b'Stacking', [[5000, b'Cisco StackWise'], [5050, b'Cisco StackWise Plus'], [5100, b'Cisco FlexStack'], [5150, b'Cisco FlexStack Plus']]], [b'Other', [[32767, b'Other']]]], default=1200),
),
]

View File

@@ -0,0 +1,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

@@ -3,16 +3,17 @@ 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
@@ -54,29 +55,6 @@ 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'],
]
# Virtual
IFACE_FF_VIRTUAL = 0
# Ethernet
@@ -107,6 +85,8 @@ IFACE_FF_E3 = 4050
# Stacking
IFACE_FF_STACKWISE = 5000
IFACE_FF_STACKWISE_PLUS = 5050
IFACE_FF_FLEXSTACK = 5100
IFACE_FF_FLEXSTACK_PLUS = 5150
# Other
IFACE_FF_OTHER = 32767
@@ -164,6 +144,8 @@ IFACE_FF_CHOICES = [
[
[IFACE_FF_STACKWISE, 'Cisco StackWise'],
[IFACE_FF_STACKWISE_PLUS, 'Cisco StackWise Plus'],
[IFACE_FF_FLEXSTACK, 'Cisco FlexStack'],
[IFACE_FF_FLEXSTACK_PLUS, 'Cisco FlexStack Plus'],
]
],
[
@@ -263,6 +245,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')
@@ -283,7 +268,10 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
self.slug,
self.tenant.name if self.tenant else '',
self.facility,
str(self.asn),
str(self.asn) if self.asn else '',
self.contact_name,
self.contact_phone,
self.contact_email,
])
@property
@@ -304,7 +292,7 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
@property
def count_circuits(self):
return self.circuits.count()
return Circuit.objects.filter(terminations__site=self).count()
#
@@ -341,7 +329,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']
@@ -375,6 +363,8 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
help_text='Rail-to-rail width')
u_height = models.PositiveSmallIntegerField(default=42, verbose_name='Height (U)',
validators=[MinValueValidator(1), MaxValueValidator(100)])
desc_units = models.BooleanField(default=False, verbose_name='Descending units',
help_text='Units are numbered top-to-bottom')
comments = models.TextField(blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
@@ -401,8 +391,11 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
if top_device:
min_height = top_device.position + top_device.device_type.u_height - 1
if self.u_height < min_height:
raise ValidationError("Rack must be at least {}U tall with currently installed devices."
.format(min_height))
raise ValidationError({
'u_height': "Rack must be at least {}U tall to house currently installed devices.".format(
min_height
)
})
def to_csv(self):
return ','.join([
@@ -415,11 +408,15 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
self.get_type_display() if self.type else '',
str(self.width),
str(self.u_height),
'True' if self.desc_units else '',
])
@property
def units(self):
return reversed(range(1, self.u_height + 1))
if self.desc_units:
return range(1, self.u_height + 1)
else:
return reversed(range(1, self.u_height + 1))
@property
def display_name(self):
@@ -438,7 +435,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
"""
elevation = OrderedDict()
for u in reversed(range(1, self.u_height + 1)):
for u in self.units:
elevation[u] = {'id': u, 'name': 'U{}'.format(u), 'face': face, 'device': None}
# Add devices to rack units list
@@ -476,7 +473,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
"""
# Gather all devices which consume U space within the rack
devices = self.devices.select_related().filter(position__gte=1).exclude(pk__in=exclude)
devices = self.devices.select_related('device_type').filter(position__gte=1).exclude(pk__in=exclude)
# Initialize the rack unit skeleton
units = range(1, self.u_height + 1)
@@ -506,9 +503,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
"""
Determine the utilization rate of the rack and return it as a percentage.
"""
if self.u_consumed is None:
self.u_consumed = 0
u_available = self.u_height - self.u_consumed
u_available = len(self.get_available_units())
return int(float(self.u_height - u_available) / self.u_height * 100)
@@ -533,7 +528,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 +560,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']
@@ -574,31 +571,65 @@ 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)
# Save a copy of u_height for validation in clean()
self._original_u_height = self.u_height
def get_absolute_url(self):
return reverse('dcim:devicetype', args=[self.pk])
def clean(self):
# If editing an existing DeviceType to have a larger u_height, first validate that *all* instances of it have
# room to expand within their racks. This validation will impose a very high performance penalty when there are
# many instances to check, but increasing the u_height of a DeviceType should be a very rare occurrence.
if self.pk is not None and self.u_height > self._original_u_height:
for d in Device.objects.filter(device_type=self, position__isnull=False):
face_required = None if self.is_full_depth else d.face
u_available = d.rack.get_available_units(u_height=self.u_height, rack_face=face_required,
exclude=[d.pk])
if d.position not in u_available:
raise ValidationError({
'u_height': "Device {} in rack {} does not have sufficient space to accommodate a height of "
"{}U".format(d, d.rack, self.u_height)
})
if not self.is_console_server and self.cs_port_templates.count():
raise ValidationError("Must delete all console server port templates associated with this device before "
"declassifying it as a console server.")
raise ValidationError({
'is_console_server': "Must delete all console server port templates associated with this device before "
"declassifying it as a console server."
})
if not self.is_pdu and self.power_outlet_templates.count():
raise ValidationError("Must delete all power outlet templates associated with this device before "
"declassifying it as a PDU.")
raise ValidationError({
'is_pdu': "Must delete all power outlet templates associated with this device before declassifying it "
"as a PDU."
})
if not self.is_network_device and self.interface_templates.filter(mgmt_only=False).count():
raise ValidationError("Must delete all non-management-only interface templates associated with this device "
"before declassifying it as a network device.")
raise ValidationError({
'is_network_device': "Must delete all non-management-only interface templates associated with this "
"device before declassifying it as a network device."
})
if self.subdevice_role != SUBDEVICE_ROLE_PARENT and self.device_bay_templates.count():
raise ValidationError("Must delete all device bay templates associated with this device before "
"declassifying it as a parent device.")
raise ValidationError({
'subdevice_role': "Must delete all device bay templates associated with this device before "
"declassifying it as a parent device."
})
if self.u_height and self.subdevice_role == SUBDEVICE_ROLE_CHILD:
raise ValidationError("Child device types must be 0U.")
raise ValidationError({
'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):
@@ -721,7 +752,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']
@@ -782,7 +813,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
rack = models.ForeignKey('Rack', related_name='devices', on_delete=models.PROTECT)
position = models.PositiveSmallIntegerField(blank=True, null=True, validators=[MinValueValidator(1)],
verbose_name='Position (U)',
help_text='Number of the lowest U position occupied by the device')
help_text='The lowest-numbered unit occupied by the device')
face = models.PositiveSmallIntegerField(blank=True, null=True, choices=RACK_FACE_CHOICES, verbose_name='Rack face')
status = models.BooleanField(choices=STATUS_CHOICES, default=STATUS_ACTIVE, verbose_name='Status')
primary_ip4 = models.OneToOneField('ipam.IPAddress', related_name='primary_ip4_for', on_delete=models.SET_NULL,
@@ -806,28 +837,40 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
def clean(self):
# Validate device type assignment
if not hasattr(self, 'device_type'):
raise ValidationError("Must specify device type.")
# Child devices cannot be assigned to a rack face/unit
if self.device_type.is_child_device and (self.face is not None or self.position):
raise ValidationError("Child device types cannot be assigned a rack face or position.")
# Validate position/face combination
if self.position and self.face is None:
raise ValidationError("Must specify rack face with rack position.")
raise ValidationError({
'face': "Must specify rack face when defining rack position."
})
# Validate rack space
rack_face = self.face if not self.device_type.is_full_depth else None
exclude_list = [self.pk] if self.pk else []
try:
available_units = self.rack.get_available_units(u_height=self.device_type.u_height, rack_face=rack_face,
exclude=exclude_list)
if self.position and self.position not in available_units:
raise ValidationError("U{} is already occupied or does not have sufficient space to accommodate a(n) "
"{} ({}U).".format(self.position, self.device_type, self.device_type.u_height))
except Rack.DoesNotExist:
# Child devices cannot be assigned to a rack face/unit
if self.device_type.is_child_device and self.face is not None:
raise ValidationError({
'face': "Child device types cannot be assigned to a rack face. This is an attribute of the parent "
"device."
})
if self.device_type.is_child_device and self.position:
raise ValidationError({
'position': "Child device types cannot be assigned to a rack position. This is an attribute of the "
"parent device."
})
# Validate rack space
rack_face = self.face if not self.device_type.is_full_depth else None
exclude_list = [self.pk] if self.pk else []
try:
available_units = self.rack.get_available_units(u_height=self.device_type.u_height, rack_face=rack_face,
exclude=exclude_list)
if self.position and self.position not in available_units:
raise ValidationError({
'position': "U{} is already occupied or does not have sufficient space to accommodate a(n) {} "
"({}U).".format(self.position, self.device_type, self.device_type.u_height)
})
except Rack.DoesNotExist:
pass
except DeviceType.DoesNotExist:
pass
def save(self, *args, **kwargs):
@@ -943,6 +986,9 @@ 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([
@@ -984,6 +1030,9 @@ 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):
"""
@@ -1002,6 +1051,9 @@ 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):
return ','.join([
@@ -1037,6 +1089,9 @@ 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):
@@ -1073,12 +1128,16 @@ 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:
raise ValidationError({'form_factor': "Virtual interfaces cannot be connected to another interface or "
"circuit. Disconnect the interface or choose a physical form "
"factor."})
raise ValidationError({
'form_factor': "Virtual interfaces cannot be connected to another interface or circuit. Disconnect the "
"interface or choose a physical form factor."
})
@property
def is_physical(self):
@@ -1087,7 +1146,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)
@@ -1104,17 +1163,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):
@@ -1129,7 +1190,9 @@ class InterfaceConnection(models.Model):
def clean(self):
if self.interface_a == self.interface_b:
raise ValidationError("Cannot connect an interface to itself")
raise ValidationError({
'interface_b': "Cannot connect an interface to itself."
})
# Used for connections export
def to_csv(self):
@@ -1158,12 +1221,16 @@ 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
if not self.device.device_type.is_parent_device:
raise ValidationError("This type of device ({}) does not support device bays."
.format(self.device.device_type))
raise ValidationError("This type of device ({}) does not support device bays.".format(
self.device.device_type
))
# Cannot install a device into itself, obviously
if self.device == self.installed_device:
@@ -1190,3 +1257,6 @@ 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 = """
@@ -72,7 +72,7 @@ STATUS_ICON = """
UTILIZATION_GRAPH = """
{% load helpers %}
{% utilization_graph record.get_utilization %}
{% utilization_graph value %}
"""
@@ -148,13 +148,12 @@ class RackTable(BaseTable):
role = tables.TemplateColumn(RACK_ROLE, verbose_name='Role')
u_height = tables.TemplateColumn("{{ record.u_height }}U", verbose_name='Height')
devices = tables.Column(accessor=Accessor('device_count'), verbose_name='Devices')
u_consumed = tables.TemplateColumn("{{ record.u_consumed|default:'0' }}U", verbose_name='Used')
utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
class Meta(BaseTable.Meta):
model = Rack
fields = ('pk', 'name', 'site', 'group', 'facility_id', 'tenant', 'role', 'u_height', 'devices', 'u_consumed',
'utilization')
fields = ('pk', 'name', 'site', 'group', 'facility_id', 'tenant', 'role', 'u_height', 'devices',
'get_utilization')
class RackImportTable(BaseTable):
@@ -196,10 +195,12 @@ class DeviceTypeTable(BaseTable):
manufacturer = tables.Column(verbose_name='Manufacturer')
model = tables.LinkColumn('dcim:devicetype', args=[Accessor('pk')], verbose_name='Device Type')
part_number = tables.Column(verbose_name='Part Number')
is_full_depth = tables.BooleanColumn(verbose_name='Full Depth')
instance_count = tables.Column(verbose_name='Instances')
class Meta(BaseTable.Meta):
model = DeviceType
fields = ('pk', 'model', 'manufacturer', 'part_number', 'u_height')
fields = ('pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count')
#
@@ -293,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
@@ -312,7 +314,8 @@ class DeviceTable(BaseTable):
site = tables.Column(accessor=Accessor('rack.site'), 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 }}")
@@ -357,7 +360,7 @@ class PowerConnectionTable(BaseTable):
args=[Accessor('power_outlet.device.pk')], verbose_name='PDU')
power_outlet = tables.Column(verbose_name='Outlet')
device = tables.LinkColumn('dcim:device', args=[Accessor('device.pk')], verbose_name='Device')
name = tables.Column(verbose_name='Console port')
name = tables.Column(verbose_name='Power Port')
class Meta(BaseTable.Meta):
model = PowerPort

View File

@@ -2,6 +2,8 @@ import json
from rest_framework import status
from rest_framework.test import APITestCase
from django.conf import settings
class SiteTest(APITestCase):
@@ -20,6 +22,9 @@ class SiteTest(APITestCase):
'asn',
'physical_address',
'shipping_address',
'contact_name',
'contact_phone',
'contact_email',
'comments',
'custom_fields',
'count_prefixes',
@@ -47,6 +52,7 @@ class SiteTest(APITestCase):
'type',
'width',
'u_height',
'desc_units',
'comments',
'custom_fields',
]
@@ -57,7 +63,7 @@ class SiteTest(APITestCase):
'embed_link',
]
def test_get_list(self, endpoint='/api/dcim/sites/'):
def test_get_list(self, endpoint='/{}api/dcim/sites/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -67,7 +73,7 @@ class SiteTest(APITestCase):
sorted(self.standard_fields),
)
def test_get_detail(self, endpoint='/api/dcim/sites/1/'):
def test_get_detail(self, endpoint='/{}api/dcim/sites/1/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -76,7 +82,7 @@ class SiteTest(APITestCase):
sorted(self.standard_fields),
)
def test_get_site_list_rack(self, endpoint='/api/dcim/sites/1/racks/'):
def test_get_site_list_rack(self, endpoint='/{}api/dcim/sites/1/racks/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -91,7 +97,7 @@ class SiteTest(APITestCase):
sorted(self.nested_fields),
)
def test_get_site_list_graphs(self, endpoint='/api/dcim/sites/1/graphs/'):
def test_get_site_list_graphs(self, endpoint='/{}api/dcim/sites/1/graphs/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -127,6 +133,7 @@ class RackTest(APITestCase):
'type',
'width',
'u_height',
'desc_units',
'comments',
'custom_fields',
]
@@ -143,13 +150,14 @@ class RackTest(APITestCase):
'type',
'width',
'u_height',
'desc_units',
'comments',
'custom_fields',
'front_units',
'rear_units'
]
def test_get_list(self, endpoint='/api/dcim/racks/'):
def test_get_list(self, endpoint='/{}api/dcim/racks/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -163,7 +171,7 @@ class RackTest(APITestCase):
sorted(SiteTest.nested_fields),
)
def test_get_detail(self, endpoint='/api/dcim/racks/1/'):
def test_get_detail(self, endpoint='/{}api/dcim/racks/1/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -192,7 +200,7 @@ class ManufacturersTest(APITestCase):
nested_fields = standard_fields
def test_get_list(self, endpoint='/api/dcim/manufacturers/'):
def test_get_list(self, endpoint='/{}api/dcim/manufacturers/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -202,7 +210,7 @@ class ManufacturersTest(APITestCase):
sorted(self.standard_fields),
)
def test_get_detail(self, endpoint='/api/dcim/manufacturers/1/'):
def test_get_detail(self, endpoint='/{}api/dcim/manufacturers/1/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -227,6 +235,9 @@ class DeviceTypeTest(APITestCase):
'is_console_server',
'is_pdu',
'is_network_device',
'subdevice_role',
'comments',
'custom_fields',
]
nested_fields = [
@@ -236,7 +247,7 @@ class DeviceTypeTest(APITestCase):
'slug'
]
def test_get_list(self, endpoint='/api/dcim/device-types/'):
def test_get_list(self, endpoint='/{}api/dcim/device-types/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -246,7 +257,7 @@ class DeviceTypeTest(APITestCase):
sorted(self.standard_fields),
)
def test_detail_list(self, endpoint='/api/dcim/device-types/1/'):
def test_detail_list(self, endpoint='/{}api/dcim/device-types/1/'.format(settings.BASE_PATH)):
# TODO: details returns list view.
# response = self.client.get(endpoint)
# content = json.loads(response.content)
@@ -270,7 +281,7 @@ class DeviceRolesTest(APITestCase):
nested_fields = ['id', 'name', 'slug']
def test_get_list(self, endpoint='/api/dcim/device-roles/'):
def test_get_list(self, endpoint='/{}api/dcim/device-roles/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -280,7 +291,7 @@ class DeviceRolesTest(APITestCase):
sorted(self.standard_fields),
)
def test_get_detail(self, endpoint='/api/dcim/device-roles/1/'):
def test_get_detail(self, endpoint='/{}api/dcim/device-roles/1/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -298,7 +309,7 @@ class PlatformsTest(APITestCase):
nested_fields = ['id', 'name', 'slug']
def test_get_list(self, endpoint='/api/dcim/platforms/'):
def test_get_list(self, endpoint='/{}api/dcim/platforms/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -308,7 +319,7 @@ class PlatformsTest(APITestCase):
sorted(self.standard_fields),
)
def test_get_detail(self, endpoint='/api/dcim/platforms/1/'):
def test_get_detail(self, endpoint='/{}api/dcim/platforms/1/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -346,7 +357,7 @@ class DeviceTest(APITestCase):
nested_fields = ['id', 'name', 'display_name']
def test_get_list(self, endpoint='/api/dcim/devices/'):
def test_get_list(self, endpoint='/{}api/dcim/devices/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -373,7 +384,7 @@ class DeviceTest(APITestCase):
sorted(RackTest.nested_fields),
)
def test_get_list_flat(self, endpoint='/api/dcim/devices/?format=json_flat'):
def test_get_list_flat(self, endpoint='/{}api/dcim/devices/?format=json_flat'.format(settings.BASE_PATH)):
flat_fields = [
'asset_tag',
@@ -421,7 +432,7 @@ class DeviceTest(APITestCase):
sorted(flat_fields),
)
def test_get_detail(self, endpoint='/api/dcim/devices/1/'):
def test_get_detail(self, endpoint='/{}api/dcim/devices/1/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -439,7 +450,7 @@ class ConsoleServerPortsTest(APITestCase):
nested_fields = ['id', 'device', 'name']
def test_get_list(self, endpoint='/api/dcim/devices/9/console-server-ports/'):
def test_get_list(self, endpoint='/{}api/dcim/devices/9/console-server-ports/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -461,7 +472,7 @@ class ConsolePortsTest(APITestCase):
nested_fields = ['id', 'device', 'name']
def test_get_list(self, endpoint='/api/dcim/devices/1/console-ports/'):
def test_get_list(self, endpoint='/{}api/dcim/devices/1/console-ports/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -479,7 +490,7 @@ class ConsolePortsTest(APITestCase):
sorted(ConsoleServerPortsTest.nested_fields),
)
def test_get_detail(self, endpoint='/api/dcim/console-ports/1/'):
def test_get_detail(self, endpoint='/{}api/dcim/console-ports/1/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -500,7 +511,7 @@ class PowerPortsTest(APITestCase):
nested_fields = ['id', 'device', 'name']
def test_get_list(self, endpoint='/api/dcim/devices/1/power-ports/'):
def test_get_list(self, endpoint='/{}api/dcim/devices/1/power-ports/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -514,7 +525,7 @@ class PowerPortsTest(APITestCase):
sorted(DeviceTest.nested_fields),
)
def test_get_detail(self, endpoint='/api/dcim/power-ports/1/'):
def test_get_detail(self, endpoint='/{}api/dcim/power-ports/1/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -535,7 +546,7 @@ class PowerOutletsTest(APITestCase):
nested_fields = ['id', 'device', 'name']
def test_get_list(self, endpoint='/api/dcim/devices/11/power-outlets/'):
def test_get_list(self, endpoint='/{}api/dcim/devices/11/power-outlets/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -585,7 +596,7 @@ class InterfaceTest(APITestCase):
'connection_status',
]
def test_get_list(self, endpoint='/api/dcim/devices/1/interfaces/'):
def test_get_list(self, endpoint='/{}api/dcim/devices/1/interfaces/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -599,7 +610,7 @@ class InterfaceTest(APITestCase):
sorted(DeviceTest.nested_fields),
)
def test_get_detail(self, endpoint='/api/dcim/interfaces/1/'):
def test_get_detail(self, endpoint='/{}api/dcim/interfaces/1/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -612,7 +623,7 @@ class InterfaceTest(APITestCase):
sorted(DeviceTest.nested_fields),
)
def test_get_graph_list(self, endpoint='/api/dcim/interfaces/1/graphs/'):
def test_get_graph_list(self, endpoint='/{}api/dcim/interfaces/1/graphs/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -622,7 +633,8 @@ class InterfaceTest(APITestCase):
sorted(SiteTest.graph_fields),
)
def test_get_interface_connections(self, endpoint='/api/dcim/interface-connections/4/'):
def test_get_interface_connections(self, endpoint='/{}api/dcim/interface-connections/4/'
.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -643,9 +655,8 @@ class RelatedConnectionsTest(APITestCase):
'interfaces',
]
def test_get_list(self, endpoint=(
'/api/dcim/related-connections/'
'?peer-device=test1-edge1&peer-interface=xe-0/0/3')):
def test_get_list(self, endpoint=('/{}api/dcim/related-connections/?peer-device=test1-edge1&peer-interface=xe-0/0/3'
.format(settings.BASE_PATH))):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)

View File

@@ -1,12 +1,9 @@
from django.conf.urls import url
from ipam.views import ServiceEditView
from secrets.views import secret_add
from . import views
from .models import (
ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, PowerPortTemplate, PowerOutletTemplate,
InterfaceTemplate,
)
urlpatterns = [
@@ -75,6 +72,7 @@ urlpatterns = [
# Interface templates
url(r'^device-types/(?P<pk>\d+)/interfaces/add/$', views.InterfaceTemplateAddView.as_view(), name='devicetype_add_interface'),
url(r'^device-types/(?P<pk>\d+)/interfaces/edit/$', views.InterfaceTemplateBulkEditView.as_view(), name='devicetype_bulkedit_interface'),
url(r'^device-types/(?P<pk>\d+)/interfaces/delete/$', views.InterfaceTemplateBulkDeleteView.as_view(), name='devicetype_delete_interface'),
# Device bay templates
@@ -107,44 +105,60 @@ 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'),
url(r'^console-ports/(?P<pk>\d+)/edit/$', views.consoleport_edit, name='consoleport_edit'),
url(r'^console-ports/(?P<pk>\d+)/delete/$', views.consoleport_delete, name='consoleport_delete'),
url(r'^console-ports/(?P<pk>\d+)/edit/$', views.ConsolePortEditView.as_view(), name='consoleport_edit'),
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'),
url(r'^console-server-ports/(?P<pk>\d+)/edit/$', views.consoleserverport_edit, name='consoleserverport_edit'),
url(r'^console-server-ports/(?P<pk>\d+)/delete/$', views.consoleserverport_delete, name='consoleserverport_delete'),
url(r'^console-server-ports/(?P<pk>\d+)/edit/$', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'),
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'),
url(r'^power-ports/(?P<pk>\d+)/edit/$', views.powerport_edit, name='powerport_edit'),
url(r'^power-ports/(?P<pk>\d+)/delete/$', views.powerport_delete, name='powerport_delete'),
url(r'^power-ports/(?P<pk>\d+)/edit/$', views.PowerPortEditView.as_view(), name='powerport_edit'),
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.poweroutlet_edit, name='poweroutlet_edit'),
url(r'^power-outlets/(?P<pk>\d+)/delete/$', views.poweroutlet_delete, name='poweroutlet_delete'),
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.devicebay_edit, name='devicebay_edit'),
url(r'^device-bays/(?P<pk>\d+)/delete/$', views.devicebay_delete, name='devicebay_delete'),
url(r'^device-bays/(?P<pk>\d+)/edit/$', views.DeviceBayEditView.as_view(), name='devicebay_edit'),
url(r'^device-bays/(?P<pk>\d+)/delete/$', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'),
url(r'^device-bays/(?P<pk>\d+)/populate/$', views.devicebay_populate, name='devicebay_populate'),
url(r'^device-bays/(?P<pk>\d+)/depopulate/$', views.devicebay_depopulate, name='devicebay_depopulate'),
@@ -156,18 +170,9 @@ 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.InterfaceBulkAddView.as_view(), name='interface_add_multi'),
url(r'^devices/(?P<pk>\d+)/interfaces/add/$', views.interface_add, name='interface_add'),
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.interface_edit, name='interface_edit'),
url(r'^interfaces/(?P<pk>\d+)/delete/$', views.interface_delete, name='interface_delete'),
# Modules
url(r'^devices/(?P<pk>\d+)/modules/add/$', views.module_add, name='module_add'),
url(r'^modules/(?P<pk>\d+)/edit/$', views.module_edit, name='module_edit'),
url(r'^modules/(?P<pk>\d+)/delete/$', views.module_delete, name='module_delete'),
url(r'^devices/(?P<device>\d+)/modules/add/$', views.ModuleEditView.as_view(), name='module_add'),
url(r'^modules/(?P<pk>\d+)/edit/$', views.ModuleEditView.as_view(), name='module_edit'),
url(r'^modules/(?P<pk>\d+)/delete/$', views.ModuleDeleteView.as_view(), name='module_delete'),
]

File diff suppressed because it is too large Load Diff

View File

@@ -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
@@ -40,7 +41,7 @@ class GraphAdmin(admin.ModelAdmin):
@admin.register(ExportTemplate)
class ExportTemplateAdmin(admin.ModelAdmin):
list_display = ['content_type', 'name', 'mime_type', 'file_extension']
list_display = ['name', 'content_type', 'description', 'mime_type', 'file_extension']
@admin.register(TopologyMap)
@@ -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

@@ -80,7 +80,7 @@ class TopologyMapView(APIView):
# Add each device to the graph
devices = []
for query in device_set.split(','):
for query in device_set.split(';'): # Split regexes on semicolons
devices += Device.objects.filter(name__regex=query)
for d in devices:
subgraph.node(d.name)
@@ -94,7 +94,7 @@ class TopologyMapView(APIView):
# Compile list of all devices
device_superset = Q()
for device_set in tmap.device_sets:
for query in device_set.split(','):
for query in device_set.split(';'): # Split regexes on semicolons
device_superset = device_superset | Q(name__regex=query)
# Add all connections to the graph

View File

@@ -2,7 +2,7 @@ import django_filters
from django.contrib.contenttypes.models import ContentType
from .models import CustomField
from .models import CF_TYPE_SELECT, CustomField
class CustomFieldFilter(django_filters.Filter):
@@ -10,9 +10,22 @@ class CustomFieldFilter(django_filters.Filter):
Filter objects by the presence of a CustomFieldValue. The filter's name is used as the CustomField name.
"""
def __init__(self, cf_type, *args, **kwargs):
self.cf_type = cf_type
super(CustomFieldFilter, self).__init__(*args, **kwargs)
def filter(self, queryset, value):
# Skip filter on empty value
if not value.strip():
return queryset
# Treat 0 as None for Select fields
try:
if self.cf_type == CF_TYPE_SELECT and int(value) == 0:
return queryset.exclude(
custom_field_values__field__name=self.name,
)
except ValueError:
pass
return queryset.filter(
custom_field_values__field__name=self.name,
custom_field_values__serialized_value=value,
@@ -30,4 +43,4 @@ class CustomFieldFilterSet(django_filters.FilterSet):
obj_type = ContentType.objects.get_for_model(self._meta.model)
custom_fields = CustomField.objects.filter(obj_type=obj_type, is_filterable=True)
for cf in custom_fields:
self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(name=cf.name)
self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(name=cf.name, cf_type=cf.type)

View File

@@ -3,6 +3,7 @@ from collections import OrderedDict
from django import forms
from django.contrib.contenttypes.models import ContentType
from utilities.forms import BulkEditForm, LaxURLField
from .models import (
CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL, CustomField, CustomFieldValue
)
@@ -43,22 +44,18 @@ 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:
if bulk_edit:
choices = [(cfc.pk, cfc) for cfc in cf.choices.all()]
if not cf.required:
choices = [(0, 'None')] + choices
choices = [(cfc.pk, cfc) for cfc in cf.choices.all()]
if not cf.required or bulk_edit or filterable_only:
choices = [(None, '---------')] + choices
field = forms.TypedChoiceField(choices=choices, coerce=int, required=cf.required)
else:
field = forms.ModelChoiceField(queryset=cf.choices.all(), required=cf.required)
field = forms.TypedChoiceField(choices=choices, coerce=int, required=cf.required)
# URL
elif cf.type == CF_TYPE_URL:
field = forms.URLField(required=cf.required, initial=cf.default)
field = LaxURLField(required=cf.required, initial=cf.default)
# Text
else:
@@ -66,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
@@ -74,10 +72,10 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
class CustomFieldForm(forms.ModelForm):
custom_fields = []
def __init__(self, *args, **kwargs):
self.custom_fields = []
self.obj_type = ContentType.objects.get_for_model(self._meta.model)
super(CustomFieldForm, self).__init__(*args, **kwargs)
@@ -94,7 +92,7 @@ class CustomFieldForm(forms.ModelForm):
existing_values = CustomFieldValue.objects.filter(obj_type=self.obj_type, obj_id=self.instance.pk)\
.select_related('field')
for cfv in existing_values:
self.initial['cf_{}'.format(str(cfv.field.name))] = cfv.value
self.initial['cf_{}'.format(str(cfv.field.name))] = cfv.serialized_value
def _save_custom_fields(self):
@@ -127,22 +125,24 @@ class CustomFieldForm(forms.ModelForm):
return obj
class CustomFieldBulkEditForm(forms.Form):
custom_fields = []
def __init__(self, model, *args, **kwargs):
self.obj_type = ContentType.objects.get_for_model(model)
class CustomFieldBulkEditForm(BulkEditForm):
def __init__(self, *args, **kwargs):
super(CustomFieldBulkEditForm, self).__init__(*args, **kwargs)
self.custom_fields = []
self.obj_type = ContentType.objects.get_for_model(self.model)
# Add all applicable CustomFields to the form
custom_fields = []
for name, field in get_custom_fields_for_model(self.obj_type, bulk_edit=True).items():
custom_fields = get_custom_fields_for_model(self.obj_type, bulk_edit=True).items()
for name, field in custom_fields:
# Annotate non-required custom fields as nullable
if not field.required:
self.nullable_fields.append(name)
field.required = False
self.fields[name] = field
custom_fields.append(name)
self.custom_fields = custom_fields
# Annotate this as a custom field
self.custom_fields.append(name)
class CustomFieldFilterForm(forms.Form):

View File

@@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10 on 2016-09-27 20:20
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '0002_custom_fields'),
]
operations = [
migrations.AddField(
model_name='exporttemplate',
name='description',
field=models.CharField(blank=True, max_length=200),
),
migrations.AlterField(
model_name='exporttemplate',
name='name',
field=models.CharField(max_length=100),
),
]

View File

@@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10 on 2016-11-03 18:33
from __future__ import unicode_literals
from django.db import migrations, models
from extras.models import TopologyMap
def commas_to_semicolons(apps, schema_editor):
for tm in TopologyMap.objects.filter(device_patterns__contains=','):
tm.device_patterns = tm.device_patterns.replace(',', ';')
tm.save()
class Migration(migrations.Migration):
dependencies = [
('extras', '0003_exporttemplate_add_description'),
]
operations = [
migrations.AlterField(
model_name='topologymap',
name='device_patterns',
field=models.TextField(help_text=b'Identify devices to include in the diagram using regular expressions, one per line. Each line will result in a new tier of the drawing. Separate multiple regexes within a line using semicolons. Devices will be rendered in the order they are defined.'),
),
migrations.RunPython(commas_to_semicolons),
]

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
@@ -67,7 +67,18 @@ ACTION_CHOICES = (
class CustomFieldModel(object):
def custom_fields(self):
def cf(self):
"""
Name-based CustomFieldValue accessor for use in templates
"""
if not hasattr(self, 'get_custom_fields'):
return dict()
return {field.name: value for field, value in self.get_custom_fields().items()}
def get_custom_fields(self):
"""
Return a dictionary of custom fields for a single object in the form {<field>: value}.
"""
# Find all custom fields applicable to this type of object
content_type = ContentType.objects.get_for_model(self)
@@ -119,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):
"""
@@ -135,8 +146,10 @@ class CustomField(models.Model):
# Read date as YYYY-MM-DD
return date(*[int(n) for n in serialized_value.split('-')])
if self.type == CF_TYPE_SELECT:
# return CustomFieldChoice.objects.get(pk=int(serialized_value))
return self.choices.get(pk=int(serialized_value))
try:
return self.choices.get(pk=int(serialized_value))
except CustomFieldChoice.DoesNotExist:
return None
return serialized_value
@@ -152,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):
@@ -187,6 +200,12 @@ class CustomFieldChoice(models.Model):
if self.field.type != CF_TYPE_SELECT:
raise ValidationError("Custom field choices can only be assigned to selection fields.")
def delete(self, using=None, keep_parents=False):
# When deleting a CustomFieldChoice, delete all CustomFieldValues which point to it
pk = self.pk
super(CustomFieldChoice, self).delete(using, keep_parents)
CustomFieldValue.objects.filter(field__type=CF_TYPE_SELECT, serialized_value=str(pk)).delete()
class Graph(models.Model):
type = models.PositiveSmallIntegerField(choices=GRAPH_TYPE_CHOICES)
@@ -214,7 +233,8 @@ class Graph(models.Model):
class ExportTemplate(models.Model):
content_type = models.ForeignKey(ContentType, limit_choices_to={'model__in': EXPORTTEMPLATE_MODELS})
name = models.CharField(max_length=200)
name = models.CharField(max_length=100)
description = models.CharField(max_length=200, blank=True)
template_code = models.TextField()
mime_type = models.CharField(max_length=15, blank=True)
file_extension = models.CharField(max_length=15, blank=True)
@@ -248,10 +268,11 @@ class TopologyMap(models.Model):
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
site = models.ForeignKey('dcim.Site', related_name='topology_maps', blank=True, null=True)
device_patterns = models.TextField(help_text="Identify devices to include in the diagram using regular expressions,"
"one per line. Each line will result in a new tier of the drawing. "
"Separate multiple regexes on a line using commas. Devices will be "
"rendered in the order they are defined.")
device_patterns = models.TextField(
help_text="Identify devices to include in the diagram using regular expressions, one per line. Each line will "
"result in a new tier of the drawing. Separate multiple regexes within a line using semicolons. "
"Devices will be rendered in the order they are defined."
)
description = models.CharField(max_length=100, blank=True)
class Meta:

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']
@@ -159,8 +159,8 @@ class IPAddressSerializer(CustomFieldSerializer, serializers.ModelSerializer):
class Meta:
model = IPAddress
fields = ['id', 'family', 'address', 'vrf', 'tenant', 'interface', 'description', 'nat_inside', 'nat_outside',
'custom_fields']
fields = ['id', 'family', 'address', 'vrf', 'tenant', 'status', 'interface', 'description', 'nat_inside',
'nat_outside', 'custom_fields']
class IPAddressNestedSerializer(IPAddressSerializer):
@@ -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

@@ -7,8 +7,9 @@ from django.db.models import Q
from dcim.models import Site, Device, Interface
from extras.filters import CustomFieldFilterSet
from tenancy.models import Tenant
from utilities.filters import NullableModelMultipleChoiceFilter
from .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):
@@ -21,12 +22,12 @@ class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet):
lookup_type='icontains',
label='Name',
)
tenant_id = django_filters.ModelMultipleChoiceFilter(
tenant_id = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
label='Tenant (ID)',
)
tenant = django_filters.ModelMultipleChoiceFilter(
tenant = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
to_field_name='slug',
@@ -42,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):
@@ -56,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)',
@@ -64,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)
@@ -85,29 +93,34 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
action='search_by_parent',
label='Parent prefix',
)
vrf = django_filters.MethodFilter(
action='_vrf',
vrf_id = NullableModelMultipleChoiceFilter(
name='vrf_id',
queryset=VRF.objects.all(),
label='VRF',
)
# Duplicate of `vrf` for backward-compatibility
vrf_id = django_filters.MethodFilter(
action='_vrf',
label='VRF',
vrf = NullableModelMultipleChoiceFilter(
name='vrf',
queryset=VRF.objects.all(),
to_field_name='rd',
label='VRF (RD)',
)
tenant_id = django_filters.MethodFilter(
action='_tenant_id',
tenant_id = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
label='Tenant (ID)',
)
tenant = django_filters.MethodFilter(
action='_tenant',
label='Tenant',
tenant = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
to_field_name='slug',
label='Tenant (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter(
site_id = NullableModelMultipleChoiceFilter(
name='site',
queryset=Site.objects.all(),
label='Site (ID)',
)
site = django_filters.ModelMultipleChoiceFilter(
site = NullableModelMultipleChoiceFilter(
name='site',
queryset=Site.objects.all(),
to_field_name='slug',
@@ -122,12 +135,12 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
name='vlan__vid',
label='VLAN number (1-4095)',
)
role_id = django_filters.ModelMultipleChoiceFilter(
role_id = NullableModelMultipleChoiceFilter(
name='role',
queryset=Role.objects.all(),
label='Role (ID)',
)
role = django_filters.ModelMultipleChoiceFilter(
role = NullableModelMultipleChoiceFilter(
name='role',
queryset=Role.objects.all(),
to_field_name='slug',
@@ -136,7 +149,7 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
class Meta:
model = Prefix
fields = ['family', 'site_id', 'site', 'vrf', 'vrf_id', 'vlan_id', 'vlan_vid', 'status', 'role_id', 'role']
fields = ['family', 'status']
def search(self, queryset, value):
qs_filter = Q(description__icontains=value)
@@ -157,17 +170,6 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
except AddrFormatError:
return queryset.none()
def _vrf(self, queryset, value):
if str(value) == '':
return queryset
try:
vrf_id = int(value)
except ValueError:
return queryset.none()
if vrf_id == 0:
return queryset.filter(vrf__isnull=True)
return queryset.filter(vrf__pk=value)
def _tenant(self, queryset, value):
if str(value) == '':
return queryset
@@ -196,22 +198,27 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
action='search_by_parent',
label='Parent prefix',
)
vrf = django_filters.MethodFilter(
action='_vrf',
vrf_id = NullableModelMultipleChoiceFilter(
name='vrf_id',
queryset=VRF.objects.all(),
label='VRF',
)
# Duplicate of `vrf` for backward-compatibility
vrf_id = django_filters.MethodFilter(
action='_vrf',
label='VRF',
vrf = NullableModelMultipleChoiceFilter(
name='vrf',
queryset=VRF.objects.all(),
to_field_name='rd',
label='VRF (RD)',
)
tenant_id = django_filters.MethodFilter(
action='_tenant_id',
tenant_id = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
label='Tenant (ID)',
)
tenant = django_filters.MethodFilter(
action='_tenant',
label='Tenant',
tenant = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
to_field_name='slug',
label='Tenant (slug)',
)
device_id = django_filters.ModelMultipleChoiceFilter(
name='interface__device',
@@ -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', 'vrf_id', 'vrf', 'device_id', 'device', 'interface_id']
fields = ['q', 'family', 'status']
def search(self, queryset, value):
qs_filter = Q(description__icontains=value)
@@ -253,35 +260,6 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
except AddrFormatError:
return queryset.none()
def _vrf(self, queryset, value):
if str(value) == '':
return queryset
try:
vrf_id = int(value)
except ValueError:
return queryset.none()
if vrf_id == 0:
return queryset.filter(vrf__isnull=True)
return queryset.filter(vrf__pk=value)
def _tenant(self, queryset, value):
if str(value) == '':
return queryset
return queryset.filter(
Q(tenant__slug=value) |
Q(tenant__isnull=True, vrf__tenant__slug=value)
)
def _tenant_id(self, queryset, value):
try:
value = int(value)
except ValueError:
return queryset.none()
return queryset.filter(
Q(tenant__pk=value) |
Q(tenant__isnull=True, vrf__tenant__pk=value)
)
class VLANGroupFilter(django_filters.FilterSet):
site_id = django_filters.ModelMultipleChoiceFilter(
@@ -290,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)',
@@ -298,7 +276,6 @@ class VLANGroupFilter(django_filters.FilterSet):
class Meta:
model = VLANGroup
fields = ['site_id', 'site']
class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
@@ -312,17 +289,17 @@ 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)',
)
group_id = django_filters.ModelMultipleChoiceFilter(
group_id = NullableModelMultipleChoiceFilter(
name='group',
queryset=VLANGroup.objects.all(),
label='Group (ID)',
)
group = django_filters.ModelMultipleChoiceFilter(
group = NullableModelMultipleChoiceFilter(
name='group',
queryset=VLANGroup.objects.all(),
to_field_name='slug',
@@ -337,23 +314,23 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
name='vid',
label='VLAN number (1-4095)',
)
tenant_id = django_filters.ModelMultipleChoiceFilter(
tenant_id = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
label='Tenant (ID)',
)
tenant = django_filters.ModelMultipleChoiceFilter(
tenant = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
to_field_name='slug',
label='Tenant (slug)',
)
role_id = django_filters.ModelMultipleChoiceFilter(
role_id = NullableModelMultipleChoiceFilter(
name='role',
queryset=Role.objects.all(),
label='Role (ID)',
)
role = django_filters.ModelMultipleChoiceFilter(
role = NullableModelMultipleChoiceFilter(
name='role',
queryset=Role.objects.all(),
to_field_name='slug',
@@ -362,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)
@@ -371,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

@@ -1,19 +1,20 @@
from django import forms
from django.db.models import Count
from dcim.models import Site, Device, Interface
from dcim.models import Site, Rack, Device, Interface
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
from tenancy.forms import bulkedit_tenant_choices
from tenancy.models import Tenant
from utilities.forms import BootstrapMixin, APISelect, Livesearch, CSVDataField, BulkImportForm, SlugField
from utilities.forms import (
APISelect, BootstrapMixin, BulkImportForm, CSVDataField, ExpandableIPAddressField, FilterChoiceField, Livesearch,
SlugField, add_blank_choice,
)
from .models import (
Aggregate, IPAddress, 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,
)
FORM_PREFIX_STATUS_CHOICES = (('', '---------'),) + PREFIX_STATUS_CHOICES
FORM_VLAN_STATUS_CHOICES = (('', '---------'),) + VLAN_STATUS_CHOICES
IP_FAMILY_CHOICES = [
('', 'All'),
(4, 'IPv4'),
@@ -21,18 +22,6 @@ IP_FAMILY_CHOICES = [
]
def bulkedit_vrf_choices():
"""
Include an option to assign the object to the global table.
"""
choices = [
(None, '---------'),
(0, 'Global'),
]
choices += [(v.pk, v.name) for v in VRF.objects.all()]
return choices
#
# VRFs
#
@@ -59,37 +48,43 @@ class VRFFromCSVForm(forms.ModelForm):
fields = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
class VRFImportForm(BulkImportForm, BootstrapMixin):
class VRFImportForm(BootstrapMixin, BulkImportForm):
csv = CSVDataField(csv_form=VRFFromCSVForm)
class VRFBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=VRF.objects.all(), widget=forms.MultipleHiddenInput)
tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
description = forms.CharField(max_length=100, required=False)
def vrf_tenant_choices():
tenant_choices = Tenant.objects.annotate(vrf_count=Count('vrfs'))
return [(t.slug, u'{} ({})'.format(t.name, t.vrf_count)) for t in tenant_choices]
class Meta:
nullable_fields = ['tenant', 'description']
class VRFFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = VRF
tenant = forms.MultipleChoiceField(required=False, choices=vrf_tenant_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('vrfs')), to_field_name='slug',
null_option=(0, None))
#
# 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'),
]))
#
@@ -117,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)
@@ -127,24 +122,22 @@ class AggregateBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
date_added = forms.DateField(required=False)
description = forms.CharField(max_length=100, required=False)
def aggregate_rir_choices():
rir_choices = RIR.objects.annotate(aggregate_count=Count('aggregates'))
return [(r.slug, u'{} ({})'.format(r.name, r.aggregate_count)) for r in rir_choices]
class Meta:
nullable_fields = ['date_added', 'description']
class AggregateFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Aggregate
family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family')
rir = forms.MultipleChoiceField(required=False, choices=aggregate_rir_choices, label='RIR',
widget=forms.SelectMultiple(attrs={'size': 8}))
rir = FilterChoiceField(queryset=RIR.objects.annotate(filter_count=Count('aggregates')), to_field_name='slug',
label='RIR')
#
# Roles
#
class RoleForm(forms.ModelForm, BootstrapMixin):
class RoleForm(BootstrapMixin, forms.ModelForm):
slug = SlugField()
class Meta:
@@ -165,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,16 +173,6 @@ class PrefixForm(BootstrapMixin, CustomFieldForm):
else:
self.fields['vlan'].choices = []
def clean_prefix(self):
prefix = self.cleaned_data['prefix']
if prefix.version == 4 and prefix.prefixlen == 32:
raise forms.ValidationError("Cannot create host addresses (/32) as prefixes. These should be IPv4 "
"addresses instead.")
elif prefix.version == 6 and prefix.prefixlen == 128:
raise forms.ValidationError("Cannot create host addresses (/128) as prefixes. These should be IPv6 "
"addresses instead.")
return prefix
class PrefixFromCSVForm(forms.ModelForm):
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, to_field_name='rd',
@@ -214,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):
@@ -246,41 +221,28 @@ class PrefixFromCSVForm(forms.ModelForm):
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)
class PrefixBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Prefix.objects.all(), widget=forms.MultipleHiddenInput)
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False)
vrf = forms.TypedChoiceField(choices=bulkedit_vrf_choices, coerce=int, required=False, label='VRF')
tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
status = forms.ChoiceField(choices=FORM_PREFIX_STATUS_CHOICES, required=False)
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF')
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
status = forms.ChoiceField(choices=add_blank_choice(PREFIX_STATUS_CHOICES), required=False)
role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False)
description = forms.CharField(max_length=100, required=False)
def prefix_vrf_choices():
vrf_choices = VRF.objects.annotate(prefix_count=Count('prefixes'))
return [(v.pk, u'{} ({})'.format(v.name, v.prefix_count)) for v in vrf_choices]
def tenant_choices():
tenant_choices = Tenant.objects.all()
return [(t.slug, t.name) for t in tenant_choices]
def prefix_site_choices():
site_choices = Site.objects.annotate(prefix_count=Count('prefixes'))
return [(s.slug, u'{} ({})'.format(s.name, s.prefix_count)) for s in site_choices]
class Meta:
nullable_fields = ['site', 'vrf', 'tenant', 'role', 'description']
def prefix_status_choices():
@@ -290,27 +252,21 @@ def prefix_status_choices():
return [(s[0], u'{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in PREFIX_STATUS_CHOICES]
def prefix_role_choices():
role_choices = Role.objects.annotate(prefix_count=Count('prefixes'))
return [(r.slug, u'{} ({})'.format(r.name, r.prefix_count)) for r in role_choices]
class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Prefix
parent = forms.CharField(required=False, label='Search Within', widget=forms.TextInput(attrs={
'placeholder': 'Network',
}))
family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family')
vrf = forms.MultipleChoiceField(required=False, choices=prefix_vrf_choices, label='VRF',
widget=forms.SelectMultiple(attrs={'size': 6}))
tenant = forms.MultipleChoiceField(required=False, choices=tenant_choices, label='Tenant',
widget=forms.SelectMultiple(attrs={'size': 6}))
status = forms.MultipleChoiceField(required=False, choices=prefix_status_choices,
widget=forms.SelectMultiple(attrs={'size': 6}))
site = forms.MultipleChoiceField(required=False, choices=prefix_site_choices,
widget=forms.SelectMultiple(attrs={'size': 6}))
role = forms.MultipleChoiceField(required=False, choices=prefix_role_choices,
widget=forms.SelectMultiple(attrs={'size': 6}))
vrf = FilterChoiceField(queryset=VRF.objects.annotate(filter_count=Count('prefixes')), to_field_name='rd',
label='VRF', null_option=(0, 'Global'))
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('prefixes')), to_field_name='slug',
null_option=(0, 'None'))
status = forms.MultipleChoiceField(choices=prefix_status_choices, required=False)
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('prefixes')), to_field_name='slug',
null_option=(0, 'None'))
role = FilterChoiceField(queryset=Role.objects.annotate(filter_count=Count('prefixes')), to_field_name='slug',
null_option=(0, 'None'))
expand = forms.BooleanField(required=False, label='Expand prefix hierarchy')
@@ -323,20 +279,17 @@ class IPAddressForm(BootstrapMixin, CustomFieldForm):
widget=forms.Select(attrs={'filter-for': 'nat_device'}))
nat_device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, label='Device',
widget=APISelect(api_url='/api/dcim/devices/?site_id={{nat_site}}',
display_field='display_name',
attrs={'filter-for': 'nat_inside'}))
livesearch = forms.CharField(required=False, label='IP Address', widget=Livesearch(
query_key='q', query_url='ipam-api:ipaddress_list', field_to_update='nat_inside', obj_label='address')
)
nat_inside = forms.ModelChoiceField(queryset=IPAddress.objects.all(), required=False, label='NAT (Inside)',
widget=APISelect(api_url='/api/ipam/ip-addresses/?device_id={{nat_device}}',
display_field='address'))
class Meta:
model = IPAddress
fields = ['address', 'vrf', 'tenant', 'nat_device', 'nat_inside', 'description']
help_texts = {
'address': "IPv4 or IPv6 address and mask",
'vrf': "VRF (if applicable)",
fields = ['address', 'vrf', 'tenant', 'status', 'nat_inside', 'description']
widgets = {
'nat_inside': APISelect(api_url='/api/ipam/ip-addresses/?device_id={{nat_device}}', display_field='address')
}
def __init__(self, *args, **kwargs):
@@ -379,11 +332,43 @@ 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')
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
status = forms.ChoiceField(choices=IPADDRESS_STATUS_CHOICES)
description = forms.CharField(max_length=100, required=False)
class IPAddressAssignForm(BootstrapMixin, forms.Form):
site = forms.ModelChoiceField(queryset=Site.objects.all(), label='Site', required=False,
widget=forms.Select(attrs={'filter-for': 'rack'}))
rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False,
widget=APISelect(api_url='/api/dcim/racks/?site_id={{site}}', display_field='display_name', attrs={'filter-for': 'device'}))
device = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False,
widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}', display_field='display_name', attrs={'filter-for': 'interface'}))
livesearch = forms.CharField(required=False, label='Device', widget=Livesearch(
query_key='q', query_url='dcim-api:device_list', field_to_update='device')
)
interface = forms.ModelChoiceField(queryset=Interface.objects.all(), label='Interface',
widget=APISelect(api_url='/api/dcim/devices/{{device}}/interfaces/'))
set_as_primary = forms.BooleanField(label='Set as primary IP for device', required=False)
def __init__(self, *args, **kwargs):
super(IPAddressAssignForm, self).__init__(*args, **kwargs)
self.fields['rack'].choices = []
self.fields['device'].choices = []
self.fields['interface'].choices = []
class IPAddressFromCSVForm(forms.ModelForm):
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, to_field_name='rd',
error_messages={'invalid_choice': 'VRF not found.'})
tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
error_messages={'invalid_choice': 'Tenant not found.'})
status_name = forms.ChoiceField(choices=[(s[1], s[0]) for s in IPADDRESS_STATUS_CHOICES])
device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, to_field_name='name',
error_messages={'invalid_choice': 'Device not found.'})
interface_name = forms.CharField(required=False)
@@ -391,7 +376,7 @@ class IPAddressFromCSVForm(forms.ModelForm):
class Meta:
model = IPAddress
fields = ['address', 'vrf', 'tenant', 'device', 'interface_name', 'is_primary', 'description']
fields = ['address', 'vrf', 'tenant', 'status_name', 'device', 'interface_name', 'is_primary', 'description']
def clean(self):
@@ -414,7 +399,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']:
@@ -427,23 +415,29 @@ 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)
class IPAddressBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=IPAddress.objects.all(), widget=forms.MultipleHiddenInput)
vrf = forms.TypedChoiceField(choices=bulkedit_vrf_choices, coerce=int, required=False, label='VRF')
tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF')
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
status = forms.ChoiceField(choices=add_blank_choice(IPADDRESS_STATUS_CHOICES), required=False)
description = forms.CharField(max_length=100, required=False)
class Meta:
nullable_fields = ['vrf', 'tenant', 'description']
def ipaddress_vrf_choices():
vrf_choices = VRF.objects.annotate(ipaddress_count=Count('ip_addresses'))
return [(v.pk, u'{} ({})'.format(v.name, v.ipaddress_count)) for v in vrf_choices]
def ipaddress_status_choices():
status_counts = {}
for status in IPAddress.objects.values('status').annotate(count=Count('status')).order_by('status'):
status_counts[status['status']] = status['count']
return [(s[0], u'{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in IPADDRESS_STATUS_CHOICES]
class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
@@ -452,17 +446,18 @@ class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
'placeholder': 'Prefix',
}))
family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family')
vrf = forms.MultipleChoiceField(required=False, choices=ipaddress_vrf_choices, label='VRF',
widget=forms.SelectMultiple(attrs={'size': 6}))
tenant = forms.MultipleChoiceField(required=False, choices=tenant_choices, label='Tenant',
widget=forms.SelectMultiple(attrs={'size': 6}))
vrf = FilterChoiceField(queryset=VRF.objects.annotate(filter_count=Count('ip_addresses')), to_field_name='rd',
label='VRF', null_option=(0, 'Global'))
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('ip_addresses')),
to_field_name='slug', null_option=(0, 'None'))
status = forms.MultipleChoiceField(choices=ipaddress_status_choices, required=False)
#
# VLAN groups
#
class VLANGroupForm(forms.ModelForm, BootstrapMixin):
class VLANGroupForm(BootstrapMixin, forms.ModelForm):
slug = SlugField()
class Meta:
@@ -470,14 +465,8 @@ class VLANGroupForm(forms.ModelForm, BootstrapMixin):
fields = ['site', 'name', 'slug']
def vlangroup_site_choices():
site_choices = Site.objects.annotate(vlangroup_count=Count('vlan_groups'))
return [(s.slug, u'{} ({})'.format(s.name, s.vlangroup_count)) for s in site_choices]
class VLANGroupFilterForm(forms.Form, BootstrapMixin):
site = forms.MultipleChoiceField(required=False, choices=vlangroup_site_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
class VLANGroupFilterForm(BootstrapMixin, forms.Form):
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('vlan_groups')), to_field_name='slug')
#
@@ -519,7 +508,7 @@ class VLANForm(BootstrapMixin, CustomFieldForm):
class VLANFromCSVForm(forms.ModelForm):
site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Device not found.'})
error_messages={'invalid_choice': 'Site not found.'})
group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False, to_field_name='name',
error_messages={'invalid_choice': 'VLAN group not found.'})
tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
@@ -541,7 +530,7 @@ class VLANFromCSVForm(forms.ModelForm):
return m
class VLANImportForm(BulkImportForm, BootstrapMixin):
class VLANImportForm(BootstrapMixin, BulkImportForm):
csv = CSVDataField(csv_form=VLANFromCSVForm)
@@ -549,25 +538,13 @@ class VLANBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=VLAN.objects.all(), widget=forms.MultipleHiddenInput)
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False)
group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False)
tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
status = forms.ChoiceField(choices=FORM_VLAN_STATUS_CHOICES, required=False)
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
status = forms.ChoiceField(choices=add_blank_choice(VLAN_STATUS_CHOICES), required=False)
role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False)
description = forms.CharField(max_length=100, required=False)
def vlan_site_choices():
site_choices = Site.objects.annotate(vlan_count=Count('vlans'))
return [(s.slug, u'{} ({})'.format(s.name, s.vlan_count)) for s in site_choices]
def vlan_group_choices():
group_choices = VLANGroup.objects.select_related('site').annotate(vlan_count=Count('vlans'))
return [(g.pk, u'{} ({})'.format(g, g.vlan_count)) for g in group_choices]
def vlan_tenant_choices():
tenant_choices = Tenant.objects.annotate(vrf_count=Count('vlans'))
return [(t.slug, u'{} ({})'.format(t.name, t.vrf_count)) for t in tenant_choices]
class Meta:
nullable_fields = ['group', 'tenant', 'role', 'description']
def vlan_status_choices():
@@ -577,19 +554,35 @@ def vlan_status_choices():
return [(s[0], u'{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in VLAN_STATUS_CHOICES]
def vlan_role_choices():
role_choices = Role.objects.annotate(vlan_count=Count('vlans'))
return [(r.slug, u'{} ({})'.format(r.name, r.vlan_count)) for r in role_choices]
class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = VLAN
site = forms.MultipleChoiceField(required=False, choices=vlan_site_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
group_id = forms.MultipleChoiceField(required=False, choices=vlan_group_choices, label='VLAN Group',
widget=forms.SelectMultiple(attrs={'size': 8}))
tenant = forms.MultipleChoiceField(required=False, choices=vlan_tenant_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
status = forms.MultipleChoiceField(required=False, choices=vlan_status_choices)
role = forms.MultipleChoiceField(required=False, choices=vlan_role_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('vlans')), to_field_name='slug')
group_id = FilterChoiceField(queryset=VLANGroup.objects.annotate(filter_count=Count('vlans')), label='VLAN group',
null_option=(0, 'None'))
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('vlans')), to_field_name='slug',
null_option=(0, 'None'))
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,19 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10 on 2016-09-15 16:08
from __future__ import unicode_literals
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('ipam', '0007_prefix_ipaddress_add_tenant'),
]
operations = [
migrations.AlterModelOptions(
name='prefix',
options={'ordering': ['vrf', 'family', 'prefix'], 'verbose_name_plural': 'prefixes'},
),
]

View File

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10 on 2016-10-21 15:44
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ipam', '0008_prefix_change_order'),
]
operations = [
migrations.AddField(
model_name='ipaddress',
name='status',
field=models.PositiveSmallIntegerField(choices=[(1, b'Active'), (2, b'Reserved'), (5, b'DHCP')], default=1, verbose_name=b'Status'),
),
]

View File

@@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10 on 2016-11-01 17:46
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', '0009_ipaddress_add_status'),
]
operations = [
migrations.AlterField(
model_name='ipaddress',
name='address',
field=ipam.fields.IPAddressField(help_text=b'IPv4 or IPv6 address (with mask)'),
),
migrations.AlterField(
model_name='ipaddress',
name='nat_inside',
field=models.OneToOneField(blank=True, help_text=b'The IP for which this address is the "outside" IP', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='nat_outside', to='ipam.IPAddress', verbose_name=b'NAT (Inside)'),
),
]

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

@@ -12,6 +12,7 @@ from dcim.models import Interface
from extras.models import CustomFieldModel, CustomFieldValue
from tenancy.models import Tenant
from utilities.models import CreatedUpdatedModel
from utilities.sql import NullsFirstQuerySet
from .fields import IPNetworkField, IPAddressField
@@ -21,17 +22,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 = (
(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 = {
@@ -39,9 +56,19 @@ STATUS_CHOICE_CLASSES = {
1: 'primary',
2: 'info',
3: 'danger',
4: 'warning',
5: 'success',
}
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
@@ -84,6 +111,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']
@@ -130,16 +159,22 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel):
if self.pk:
covering_aggregates = covering_aggregates.exclude(pk=self.pk)
if covering_aggregates:
raise ValidationError("{} is already covered by an existing aggregate ({})"
.format(self.prefix, covering_aggregates[0]))
raise ValidationError({
'prefix': "Aggregates cannot overlap. {} is already covered by an existing aggregate ({}).".format(
self.prefix, covering_aggregates[0]
)
})
# Ensure that the aggregate being added does not cover an existing aggregate
covered_aggregates = Aggregate.objects.filter(prefix__net_contained=str(self.prefix))
if self.pk:
covered_aggregates = covered_aggregates.exclude(pk=self.pk)
if covered_aggregates:
raise ValidationError("{} is overlaps with an existing aggregate ({})"
.format(self.prefix, covered_aggregates[0]))
raise ValidationError({
'prefix': "Aggregates cannot overlap. {} covers an existing aggregate ({}).".format(
self.prefix, covered_aggregates[0]
)
})
def save(self, *args, **kwargs):
if self.prefix:
@@ -192,7 +227,7 @@ class Role(models.Model):
return self.vlans.count()
class PrefixQuerySet(models.QuerySet):
class PrefixQuerySet(NullsFirstQuerySet):
def annotate_depth(self, limit=None):
"""
@@ -234,22 +269,26 @@ 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')
objects = PrefixQuerySet.as_manager()
class Meta:
ordering = ['family', 'prefix']
ordering = ['vrf', 'family', 'prefix']
verbose_name_plural = 'prefixes'
def __unicode__(self):
@@ -259,14 +298,17 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
return reverse('ipam:prefix', args=[self.pk])
def clean(self):
# Disallow host masks
if self.prefix:
if self.prefix.version == 4 and self.prefix.prefixlen == 32:
raise ValidationError("Cannot create host addresses (/32) as prefixes. These should be IPv4 addresses "
"instead.")
raise ValidationError({
'prefix': "Cannot create host addresses (/32) as prefixes. Create an IPv4 address instead."
})
elif self.prefix.version == 6 and self.prefix.prefixlen == 128:
raise ValidationError("Cannot create host addresses (/128) as prefixes. These should be IPv6 addresses "
"instead.")
raise ValidationError({
'prefix': "Cannot create host addresses (/128) as prefixes. Create an IPv6 address instead."
})
def save(self, *args, **kwargs):
if self.prefix:
@@ -282,8 +324,11 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
self.vrf.rd if self.vrf else '',
self.tenant.name if self.tenant else '',
self.site.name if self.site else '',
self.vlan.group.name if self.vlan and self.vlan.group else '',
str(self.vlan.vid) if self.vlan else '',
self.get_status_display(),
self.role.name if self.role else '',
'True' if self.is_pool else '',
self.description,
])
@@ -328,14 +373,16 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
which has a NAT outside IP, that Interface's Device can use either the inside or outside IP as its primary IP.
"""
family = models.PositiveSmallIntegerField(choices=AF_CHOICES, editable=False)
address = IPAddressField()
address = IPAddressField(help_text="IPv4 or IPv6 address (with mask)")
vrf = models.ForeignKey('VRF', related_name='ip_addresses', on_delete=models.PROTECT, blank=True, null=True,
verbose_name='VRF')
tenant = models.ForeignKey(Tenant, related_name='ip_addresses', blank=True, null=True, on_delete=models.PROTECT)
status = models.PositiveSmallIntegerField('Status', choices=IPADDRESS_STATUS_CHOICES, default=1)
interface = models.ForeignKey(Interface, related_name='ip_addresses', on_delete=models.CASCADE, blank=True,
null=True)
nat_inside = models.OneToOneField('self', related_name='nat_outside', on_delete=models.SET_NULL, blank=True,
null=True, verbose_name='NAT IP (inside)')
null=True, verbose_name='NAT (Inside)',
help_text="The IP for which this address is the \"outside\" IP")
description = models.CharField(max_length=100, blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
@@ -359,13 +406,16 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
duplicate_ips = IPAddress.objects.filter(vrf=self.vrf, address__net_host=str(self.address.ip))\
.exclude(pk=self.pk)
if duplicate_ips:
raise ValidationError("Duplicate IP address found in VRF {}: {}".format(self.vrf,
duplicate_ips.first()))
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("Duplicate IP address found in global table: {}".format(duplicate_ips.first()))
raise ValidationError({
'address': "Duplicate IP address found in global table: {}".format(duplicate_ips.first())
})
def save(self, *args, **kwargs):
if self.address:
@@ -386,6 +436,7 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
str(self.address),
self.vrf.rd if self.vrf else '',
self.tenant.name if self.tenant else '',
self.get_status_display(),
self.device.identifier if self.device else '',
self.interface.name if self.interface else '',
'True' if is_primary else '',
@@ -398,6 +449,9 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
return self.interface.device
return None
def get_status_class(self):
return STATUS_CHOICE_CLASSES[self.status]
class VLANGroup(models.Model):
"""
@@ -464,7 +518,9 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
# Validate VLAN group
if self.group and self.group.site != self.site:
raise ValidationError("VLAN group must belong to the assigned site ({}).".format(self.site))
raise ValidationError({
'group': "VLAN group must belong to the assigned site ({}).".format(self.site)
})
def to_csv(self):
return ','.join([
@@ -484,3 +540,28 @@ 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())
def get_parent_url(self):
return self.device.get_absolute_url()

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>
@@ -14,7 +33,7 @@ RIR_ACTIONS = """
UTILIZATION_GRAPH = """
{% load helpers %}
{% utilization_graph record.get_utilization %}
{% utilization_graph value %}
"""
ROLE_ACTIONS = """
@@ -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>
@@ -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')
#
@@ -125,13 +180,13 @@ class AggregateTable(BaseTable):
prefix = tables.LinkColumn('ipam:aggregate', args=[Accessor('pk')], verbose_name='Aggregate')
rir = tables.Column(verbose_name='RIR')
child_count = tables.Column(verbose_name='Prefixes')
utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
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')
class Meta(BaseTable.Meta):
model = Aggregate
fields = ('pk', 'prefix', 'rir', 'child_count', 'utilization', 'date_added', 'description')
fields = ('pk', 'prefix', 'rir', 'child_count', 'get_utilization', 'date_added', 'description')
#
@@ -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')
vlan = tables.LinkColumn('ipam:vlan', args=[Accessor('vlan.pk')], verbose_name='VLAN')
role = tables.TemplateColumn(PREFIX_ROLE_LINK, verbose_name='Role')
description = tables.Column(orderable=False, verbose_name='Description')
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 '',
}
@@ -193,6 +249,7 @@ class PrefixBriefTable(BaseTable):
class IPAddressTable(BaseTable):
pk = ToggleColumn()
address = tables.TemplateColumn(IPADDRESS_LINK, verbose_name='IP Address')
status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
tenant = tables.TemplateColumn(TENANT_LINK, verbose_name='Tenant')
device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False,
@@ -202,7 +259,7 @@ class IPAddressTable(BaseTable):
class Meta(BaseTable.Meta):
model = IPAddress
fields = ('pk', 'address', 'vrf', 'tenant', 'device', 'interface', 'description')
fields = ('pk', 'address', 'status', 'vrf', 'tenant', 'device', 'interface', 'description')
row_attrs = {
'class': lambda record: 'success' if not isinstance(record, IPAddress) else '',
}
@@ -249,10 +306,11 @@ 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')
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')

View File

@@ -51,11 +51,14 @@ 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'),
url(r'^ip-addresses/(?P<pk>\d+)/$', views.ipaddress, name='ipaddress'),
url(r'^ip-addresses/(?P<pk>\d+)/edit/$', views.IPAddressEditView.as_view(), name='ipaddress_edit'),
url(r'^ip-addresses/(?P<pk>\d+)/assign/$', views.ipaddress_assign, name='ipaddress_assign'),
url(r'^ip-addresses/(?P<pk>\d+)/remove/$', views.ipaddress_remove, name='ipaddress_remove'),
url(r'^ip-addresses/(?P<pk>\d+)/delete/$', views.IPAddressDeleteView.as_view(), name='ipaddress_delete'),
# VLAN groups
@@ -74,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,18 +1,25 @@
import netaddr
from django_tables2 import RequestConfig
import netaddr
from django.contrib.auth.decorators import permission_required
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.contrib import messages
from django.core.urlresolvers import reverse
from django.db.models import Count, Q
from django.shortcuts import get_object_or_404, render
from django.shortcuts import get_object_or_404, redirect, render
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):
@@ -31,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:
@@ -112,7 +116,7 @@ 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):
@@ -149,17 +153,95 @@ 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'
obj_list_url = 'ipam:rir_list'
use_obj_view = False
class RIRBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -208,7 +290,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)
@@ -224,7 +305,7 @@ 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):
@@ -270,8 +351,8 @@ 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'
obj_list_url = 'ipam:role_list'
use_obj_view = False
class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -285,7 +366,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
@@ -334,7 +415,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)
@@ -355,7 +435,7 @@ 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):
@@ -393,10 +473,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)
@@ -446,13 +525,80 @@ def ipaddress(request, pk):
})
@permission_required(['dcim.change_device', 'ipam.change_ipaddress'])
def ipaddress_assign(request, pk):
ipaddress = get_object_or_404(IPAddress, pk=pk)
if request.method == 'POST':
form = forms.IPAddressAssignForm(request.POST)
if form.is_valid():
interface = form.cleaned_data['interface']
ipaddress.interface = interface
ipaddress.save()
messages.success(request, u"Assigned IP address {} to interface {}.".format(ipaddress, ipaddress.interface))
if form.cleaned_data['set_as_primary']:
device = interface.device
if ipaddress.family == 4:
device.primary_ip4 = ipaddress
elif ipaddress.family == 6:
device.primary_ip6 = ipaddress
device.save()
return redirect('ipam:ipaddress', pk=ipaddress.pk)
else:
form = forms.IPAddressAssignForm()
return render(request, 'ipam/ipaddress_assign.html', {
'ipaddress': ipaddress,
'form': form,
'cancel_url': reverse('ipam:ipaddress', kwargs={'pk': ipaddress.pk}),
})
@permission_required(['dcim.change_device', 'ipam.change_ipaddress'])
def ipaddress_remove(request, pk):
ipaddress = get_object_or_404(IPAddress, pk=pk)
if request.method == 'POST':
form = ConfirmationForm(request.POST)
if form.is_valid():
device = ipaddress.interface.device
ipaddress.interface = None
ipaddress.save()
messages.success(request, u"Removed IP address {} from {}.".format(ipaddress, device))
if device.primary_ip4 == ipaddress.pk:
device.primary_ip4 = None
device.save()
elif device.primary_ip6 == ipaddress.pk:
device.primary_ip6 = None
device.save()
return redirect('ipam:ipaddress', pk=ipaddress.pk)
else:
form = ConfirmationForm()
return render(request, 'ipam/ipaddress_unassign.html', {
'ipaddress': ipaddress,
'form': form,
'cancel_url': reverse('ipam:ipaddress', kwargs={'pk': ipaddress.pk}),
})
class IPAddressEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.change_ipaddress'
model = IPAddress
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):
@@ -461,6 +607,14 @@ class IPAddressDeleteView(PermissionRequiredMixin, ObjectDeleteView):
redirect_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'
class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'ipam.add_ipaddress'
form = forms.IPAddressImportForm
@@ -515,8 +669,8 @@ 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'
obj_list_url = 'ipam:vlangroup_list'
use_obj_view = False
class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -530,7 +684,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
@@ -555,7 +709,7 @@ 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):
@@ -584,3 +738,24 @@ 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
class ServiceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_service'
model = Service

View File

@@ -9,7 +9,7 @@ import os
# access to the server via any other hostnames. The first FQDN in the list will be treated as the preferred name.
#
# Example: ALLOWED_HOSTS = ['netbox.example.com', 'netbox.internal.local']
ALLOWED_HOSTS = [os.environ.get('ALLOWED_HOSTS', '')]
ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '').split(' ')
# PostgreSQL database configuration.
DATABASE = {
@@ -52,6 +52,10 @@ EMAIL = {
# are permitted to access most data in NetBox (excluding secrets) but not make any changes.
LOGIN_REQUIRED = os.environ.get('LOGIN_REQUIRED', False)
# Base URL path if accessing NetBox within a directory. For example, if installed at http://example.com/netbox/, set:
# BASE_PATH = 'netbox/'
BASE_PATH = os.environ.get('BASE_PATH', '')
# Setting this to True will display a "maintenance mode" banner at the top of every page.
MAINTENANCE_MODE = os.environ.get('MAINTENANCE_MODE', False)

View File

@@ -52,6 +52,10 @@ EMAIL = {
# are permitted to access most data in NetBox (excluding secrets) but not make any changes.
LOGIN_REQUIRED = False
# Base URL path if accessing NetBox within a directory. For example, if installed at http://example.com/netbox/, set:
# BASE_PATH = 'netbox/'
BASE_PATH = ''
# Setting this to True will display a "maintenance mode" banner at the top of every page.
MAINTENANCE_MODE = False

View File

@@ -12,7 +12,7 @@ except ImportError:
"the documentation.")
VERSION = '1.6.0'
VERSION = '1.8.0'
# Import local configuration
for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:
@@ -27,6 +27,9 @@ ADMINS = getattr(configuration, 'ADMINS', [])
DEBUG = getattr(configuration, 'DEBUG', False)
EMAIL = getattr(configuration, 'EMAIL', {})
LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False)
BASE_PATH = getattr(configuration, 'BASE_PATH', '')
if BASE_PATH:
BASE_PATH = BASE_PATH.strip('/') + '/' # Enforce trailing slash only
MAINTENANCE_MODE = getattr(configuration, 'MAINTENANCE_MODE', False)
PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50)
NETBOX_USERNAME = getattr(configuration, 'NETBOX_USERNAME', '')
@@ -71,7 +74,7 @@ if LDAP_CONFIGURED:
logger.setLevel(logging.DEBUG)
except ImportError:
raise ImproperlyConfigured("LDAP authentication has been configured, but django-auth-ldap is not installed. "
"You can remove netbox/ldap.py to disable LDAP.")
"You can remove netbox/ldap_config.py to disable LDAP.")
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -114,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',
@@ -159,7 +163,7 @@ USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.8/howto/static-files/
STATIC_ROOT = BASE_DIR + '/static/'
STATIC_URL = '/static/'
STATIC_URL = '/{}static/'.format(BASE_PATH)
STATICFILES_DIRS = (
os.path.join(BASE_DIR, "project-static"),
)
@@ -173,8 +177,7 @@ MESSAGE_TAGS = {
}
# Authentication URLs
LOGIN_URL = '/login/'
LOGIN_REDIRECT_URL = '/'
LOGIN_URL = '/{}login/'.format(BASE_PATH)
# Secrets
SECRETS_MIN_PUBKEY_SIZE = 2048
@@ -183,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

@@ -1,14 +1,14 @@
from django.conf import settings
from django.conf.urls import include, url
from django.contrib import admin
from django.views.defaults import page_not_found
from views import home, trigger_500, handle_500
from views import home, handle_500, trigger_500
from users.views import login, logout
handler500 = handle_500
urlpatterns = [
_patterns = [
# Default page
url(r'^$', home, name='home'),
@@ -35,10 +35,20 @@ urlpatterns = [
url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework')),
# Error testing
url(r'^404/$', page_not_found),
url(r'^500/$', trigger_500),
# Admin
url(r'^admin/', include(admin.site.urls)),
]
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

@@ -47,16 +47,20 @@ def home(request):
})
def trigger_500(request):
"""Hot-wired method of triggering a server error to test reporting."""
raise Exception("Congratulations, you've triggered an exception! Go tell all your friends what an exceptional "
"person you are.")
def handle_500(request):
"""Custom server error handler"""
"""
Custom server error handler
"""
type_, error, traceback = sys.exc_info()
return render(request, '500.html', {
'exception': str(type_),
'error': error,
}, status=500)
def trigger_500(request):
"""
Hot-wired method of triggering a server error to test reporting
"""
raise Exception("Congratulations, you've triggered an exception! Go tell all your friends what an exceptional "
"person you are.")

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

@@ -1,16 +1,30 @@
$(document).ready(function() {
// "Select all" checkbox in a table header
$('th input:checkbox[name=_all]').click(function (event) {
$(this).parents('table').find('td input:checkbox').prop('checked', $(this).prop('checked'));
// "Toggle all" checkbox (table header)
$('#toggle_all').click(function (event) {
$('td input:checkbox[name=pk]').prop('checked', $(this).prop('checked'));
if ($(this).is(':checked')) {
$('#select_all_box').removeClass('hidden');
} else {
$('#select_all').prop('checked', false);
}
});
// Uncheck the "select all" checkbox if an item is unchecked
// Uncheck the "toggle all" checkbox if an item is unchecked
$('input:checkbox[name=pk]').click(function (event) {
if (!$(this).attr('checked')) {
$(this).parents('table').find('input:checkbox[name=_all]').prop('checked', false);
$('#select_all, #toggle_all').prop('checked', false);
}
});
// Simple "Toggle all" button (panel)
$('button.toggle').click(function (event) {
var selected = $(this).attr('selected');
$(this).closest('form').find('input:checkbox[name=pk]').prop('checked', !selected);
$(this).attr('selected', !selected);
$(this).children('span').toggleClass('glyphicon-unchecked glyphicon-check');
return false;
});
// Slugify
function slugify(s, num_chars) {
s = s.replace(/[^\-\.\w\s]/g, ''); // Remove unneeded chars
@@ -25,13 +39,26 @@ $(document).ready(function() {
});
if (slug_field) {
var slug_source = $('#id_' + slug_field.attr('slug-source'));
slug_source.keyup(function() {
slug_source.on('keyup change', function() {
if (slug_field && !slug_field.attr('_changed')) {
slug_field.val(slugify($(this).val(), 50));
}
})
}
// Bulk edit nullification
$('input:checkbox[name=_nullify]').click(function (event) {
$('#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

@@ -34,7 +34,7 @@ class UserKeyAdmin(admin.ModelAdmin):
try:
my_userkey = UserKey.objects.get(user=request.user)
except UserKey.DoesNotExist:
messages.error(request, "You do not have an active User Key.")
messages.error(request, u"You do not have an active User Key.")
return redirect('/admin/secrets/userkey/')
if 'activate' in request.POST:
@@ -46,7 +46,7 @@ class UserKeyAdmin(admin.ModelAdmin):
uk.activate(master_key)
return redirect('/admin/secrets/userkey/')
except ValueError:
messages.error(request, "Invalid private key provided. Unable to retrieve master key.")
messages.error(request, u"Invalid private key provided. Unable to retrieve master key.")
else:
form = ActivateUserKeyForm(initial={'_selected_action': request.POST.getlist(admin.ACTION_CHECKBOX_NAME)})

View File

@@ -14,10 +14,10 @@ def userkey_required():
try:
uk = UserKey.objects.get(user=request.user)
except UserKey.DoesNotExist:
messages.warning(request, "This operation requires an active user key, but you don't have one.")
messages.warning(request, u"This operation requires an active user key, but you don't have one.")
return redirect('users:userkey')
if not uk.is_active():
messages.warning(request, "This operation is not available. Your user key has not been activated.")
messages.warning(request, u"This operation is not available. Your user key has not been activated.")
return redirect('users:userkey')
return view(request, *args, **kwargs)
return wrapped_view

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

@@ -5,7 +5,7 @@ from django import forms
from django.db.models import Count
from dcim.models import Device
from utilities.forms import BootstrapMixin, BulkImportForm, CSVDataField, SlugField
from utilities.forms import BootstrapMixin, BulkEditForm, BulkImportForm, CSVDataField, FilterChoiceField, SlugField
from .models import Secret, SecretRole, UserKey
@@ -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,25 +46,26 @@ 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.TextInput(attrs={'class': 'requires-private-key'}))
plaintext2 = forms.CharField(max_length=65535, required=False, label='Plaintext (verify)')
widget=forms.PasswordInput(attrs={'class': 'requires-private-key'}))
plaintext2 = forms.CharField(max_length=65535, required=False, label='Plaintext (verify)',
widget=forms.PasswordInput())
class Meta:
model = Secret
fields = ['role', 'name', 'plaintext', 'plaintext2']
def clean(self):
if self.cleaned_data['plaintext']:
validate_rsa_key(self.cleaned_data['private_key'])
def clean_plaintext2(self):
plaintext = self.cleaned_data['plaintext']
plaintext2 = self.cleaned_data['plaintext2']
if plaintext != plaintext2:
raise forms.ValidationError("The two given plaintext values do not match. Please check your input.")
if self.cleaned_data['plaintext'] != self.cleaned_data['plaintext2']:
raise forms.ValidationError({
'plaintext2': "The two given plaintext values do not match. Please check your input."
})
class SecretFromCSVForm(forms.ModelForm):
@@ -84,31 +85,29 @@ 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(forms.Form, BootstrapMixin):
class SecretBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Secret.objects.all(), widget=forms.MultipleHiddenInput)
role = forms.ModelChoiceField(queryset=SecretRole.objects.all())
role = forms.ModelChoiceField(queryset=SecretRole.objects.all(), required=False)
name = forms.CharField(max_length=100, required=False)
def secret_role_choices():
role_choices = SecretRole.objects.annotate(secret_count=Count('secrets'))
return [(r.slug, u'{} ({})'.format(r.name, r.secret_count)) for r in role_choices]
class Meta:
nullable_fields = ['name']
class SecretFilterForm(forms.Form, BootstrapMixin):
role = forms.MultipleChoiceField(required=False, choices=secret_role_choices)
class SecretFilterForm(BootstrapMixin, forms.Form):
role = FilterChoiceField(queryset=SecretRole.objects.annotate(filter_count=Count('secrets')), to_field_name='slug')
#
# UserKeys
#
class UserKeyForm(forms.ModelForm, BootstrapMixin):
class UserKeyForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = UserKey

View File

@@ -81,24 +81,34 @@ class UserKey(CreatedUpdatedModel):
def clean(self, *args, **kwargs):
# Validate the public key format and length.
if self.public_key:
# Validate the public key format
try:
pubkey = RSA.importKey(self.public_key)
except ValueError:
raise ValidationError("Invalid RSA key format.")
raise ValidationError({
'public_key': "Invalid RSA key format."
})
except:
raise ValidationError("Something went wrong while trying to save your key. Please ensure that you're "
"uploading a valid RSA public key in PEM format (no SSH/PGP).")
# key.size() returns 1 less than the key modulus
pubkey_length = pubkey.size() + 1
# Validate the public key length
pubkey_length = pubkey.size() + 1 # key.size() returns 1 less than the key modulus
if pubkey_length < settings.SECRETS_MIN_PUBKEY_SIZE:
raise ValidationError("Insufficient key length. Keys must be at least {} bits long."
.format(settings.SECRETS_MIN_PUBKEY_SIZE))
raise ValidationError({
'public_key': "Insufficient key length. Keys must be at least {} bits long.".format(
settings.SECRETS_MIN_PUBKEY_SIZE
)
})
# We can't use keys bigger than our master_key_cipher field can hold
if pubkey_length > 4096:
raise ValidationError("Public key size ({}) is too large. Maximum key size is 4096 bits."
.format(pubkey_length))
raise ValidationError({
'public_key': "Public key size ({}) is too large. Maximum key size is 4096 bits.".format(
pubkey_length
)
})
super(UserKey, self).clean()

View File

@@ -30,8 +30,8 @@ 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'
obj_list_url = 'secrets:secretrole_list'
use_obj_view = False
class SecretRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -90,7 +90,7 @@ def secret_add(request, pk):
secret.encrypt(master_key)
secret.save()
messages.success(request, "Added new secret: {0}".format(secret))
messages.success(request, u"Added new secret: {}.".format(secret))
if '_addanother' in request.POST:
return redirect('dcim:device_addsecret', pk=device.pk)
else:
@@ -135,7 +135,7 @@ def secret_edit(request, pk):
else:
secret = form.save()
messages.success(request, "Modified secret {0}".format(secret))
messages.success(request, u"Modified secret {}.".format(secret))
return redirect('secrets:secret', pk=secret.pk)
else:
@@ -180,7 +180,7 @@ def secret_import(request):
new_secrets.append(secret)
table = tables.SecretTable(new_secrets)
messages.success(request, "Imported {} new secrets".format(len(new_secrets)))
messages.success(request, u"Imported {} new secrets.".format(len(new_secrets)))
return render(request, 'import_success.html', {
'table': table,

19
netbox/templates/404.html Normal file
View File

@@ -0,0 +1,19 @@
{% extends '_base.html' %}
{% block content %}
<div class="row" style="margin-top: 150px;">
<div class="col-sm-4 col-sm-offset-4">
<div class="panel panel-default">
<div class="panel-heading">
<strong><i class="glyphicon glyphicon-warning-sign"></i> Page Not Found</strong>
</div>
<div class="panel-body">
The requested page does not exist.
</div>
<div class="panel-footer text-right">
<a href="{% url 'home' %}" class="btn btn-xs btn-primary">Home Page</a>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -26,7 +26,7 @@
<pre><strong>{{ exception }}</strong><br />
{{ error }}</pre>
<div class="text-right">
<a href="/" class="btn btn-primary">Home Page</a>
<a href="{% url 'home' %}" class="btn btn-primary">Home Page</a>
</div>
</div>
</div>

View File

@@ -9,6 +9,7 @@
<link rel="stylesheet" href="{% static 'jquery-ui-1.11.4/jquery-ui.css' %}">
<link rel="stylesheet" href="{% static 'css/base.css' %}">
<link rel="icon" type="image/png" href="{% static 'img/netbox.ico' %}" />
<meta name="viewport" content="initial-scale=1, maximum-scale=1, user-scalable=no, width=device-width">
</head>
<body>
<nav class="navbar navbar-default navbar-fixed-top">
@@ -20,7 +21,7 @@
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="/">
<a class="navbar-brand" href="{% url 'home' %}">
<img src="{% static 'img/netbox_logo.png' %}" />
</a>
</div>
@@ -288,7 +289,7 @@
<div class="col-xs-4 text-right">
<p class="text-muted">
<i class="fa fa-fw fa-book text-primary"></i> <a href="http://netbox.readthedocs.io/" target="_blank">Docs</a> &middot;
<i class="fa fa-fw fa-cloud text-primary"></i> <a href="/api/docs/">API</a> &middot;
<i class="fa fa-fw fa-cloud text-primary"></i> <a href="{% url 'django.swagger.base.view' %}">API</a> &middot;
<i class="fa fa-fw fa-code text-primary"></i> <a href="https://github.com/digitalocean/netbox">Code</a>
</p>
</div>

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>
@@ -104,52 +94,9 @@
</tr>
</table>
</div>
{% 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>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>
{% with circuit.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>
@@ -162,6 +109,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

@@ -3,14 +3,21 @@
{% block title %}Circuit Bulk Edit{% endblock %}
{% block select_objects_table %}
{% block selected_objects_table %}
<tr>
<th>Circuit</th>
<th>Type</th>
<th>Provider</th>
<th>Port speed</th>
<th>Commit rate</th>
</tr>
{% for circuit in selected_objects %}
<tr>
<td><a href="{% url 'circuits:circuit' pk=circuit.pk %}">{{ circuit }}</a></td>
<td>{{ circuit.type }}</td>
<td>{{ circuit.provider }}</td>
<td>{{ circuit.port_speed }} Kbps</td>
<td>{{ circuit.commit_rate }}</td>
<td>{{ circuit.port_speed_human }}</td>
<td>{{ circuit.commit_rate_human }}</td>
</tr>
{% endfor %}
{% endblock %}

View File

@@ -1,5 +1,4 @@
{% extends 'utilities/obj_edit.html' %}
{% load static from staticfiles %}
{% load form_helpers %}
{% block form %}
@@ -11,15 +10,6 @@
{% 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 %}
</div>
</div>
@@ -31,26 +21,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 +28,3 @@
</div>
</div>
{% endblock %}
{% block javascript %}
<script src="{% static 'js/livesearch.js' %}"></script>
{% endblock %}

View File

@@ -48,45 +48,20 @@
<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>
</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</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 %}" 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>
@@ -105,7 +106,7 @@
</tr>
</table>
</div>
{% with provider.custom_fields as custom_fields %}
{% with provider.get_custom_fields as custom_fields %}
{% include 'inc/custom_fields_panel.html' %}
{% endwith %}
<div class="panel panel-default">
@@ -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

@@ -3,7 +3,12 @@
{% block title %}Provider Bulk Edit{% endblock %}
{% block select_objects_table %}
{% block selected_objects_table %}
<tr>
<th>Provider</th>
<th>Account</th>
<th>ASN</th>
</tr>
{% for provider in selected_objects %}
<tr>
<td><a href="{% url 'circuits:provider' slug=provider.slug %}">{{ provider }}</a></td>

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

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

View File

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

View File

@@ -8,12 +8,12 @@
{% block content %}
{% 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>
@@ -144,7 +144,7 @@
</tr>
</table>
</div>
{% with device.custom_fields as custom_fields %}
{% with device.get_custom_fields as custom_fields %}
{% include 'inc/custom_fields_panel.html' %}
{% endwith %}
{% if request.user.is_authenticated %}
@@ -186,16 +186,44 @@
{% include 'dcim/inc/_ipaddress.html' %}
{% endfor %}
</table>
{% else %}
{% elif interfaces or mgmt_interfaces %}
<div class="panel-body text-muted">
None found
None assigned
</div>
{% else %}
<div class="panel-body">
<a href="{% url 'dcim:interface_add' pk=device.pk %}">Create an interface</a> to assign an IP.
</div>
{% endif %}
{% if perms.ipam.add_ipaddress %}
{% if interfaces or mgmt_interfaces %}
<div class="panel-footer text-right">
<a href="{% url 'dcim:ipaddress_assign' pk=device.pk %}" class="btn btn-xs btn-primary">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Assign IP address
</a>
</div>
{% 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:ipaddress_assign' pk=device.pk %}" class="btn btn-xs btn-primary">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
Assign IP address
<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 %}
@@ -210,7 +238,7 @@
{% empty %}
<tr>
<td colspan="5" class="alert-warning">
<i class="fa fa-fw fa-warning"></i> No management interfaces defined!
<i class="fa fa-fw fa-warning"></i> No management interfaces defined
{% if perms.dcim.add_interface %}
<a href="{% url 'dcim:interface_add' pk=device.pk %}?mgmt_only=1" class="btn btn-primary btn-xs pull-right"><span class="glyphicon glyphicon-plus" aria-hidden="true"></span></a>
{% endif %}
@@ -222,7 +250,7 @@
{% empty %}
<tr>
<td colspan="5" class="alert-warning">
<i class="fa fa-fw fa-warning"></i> No console ports defined!
<i class="fa fa-fw fa-warning"></i> No console ports defined
{% if perms.dcim.add_consoleport %}
<a href="{% url 'dcim:consoleport_add' pk=device.pk %}" class="btn btn-primary btn-xs pull-right"><span class="glyphicon glyphicon-plus" aria-hidden="true"></span></a>
{% endif %}
@@ -232,36 +260,31 @@
{% for pp in power_ports %}
{% 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 %}
<div class="panel-footer text-right">
{% if perms.dcim.add_interface %}
<a href="{% url 'dcim:interface_add' pk=device.pk %}?mgmt_only=1" class="btn btn-xs btn-primary">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
Add interface
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interface
</a>
{% endif %}
{% if perms.dcim.add_consoleport %}
<a href="{% url 'dcim:consoleport_add' pk=device.pk %}" class="btn btn-xs btn-primary">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
Add console
<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
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power port
</a>
{% endif %}
</div>
@@ -293,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>
@@ -301,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 %}">
@@ -312,6 +334,18 @@
<div class="panel panel-default">
<div class="panel-heading">
<strong>Device Bays</strong>
<div class="pull-right">
{% 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
</a>
{% endif %}
</div>
</div>
<table class="table table-hover panel-body">
{% for devicebay in device_bays %}
@@ -324,23 +358,19 @@
</table>
{% if perms.dcim.add_devicebay or perms.dcim.delete_devicebay %}
<div class="panel-footer">
<div class="row">
<div class="col-md-6">
{% if device_bays and perms.dcim.delete_devicebay %}
<button type="submit" class="btn btn-xs btn-danger">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Selected
</button>
{% endif %}
{% if device_bays and perms.dcim.delete_devicebay %}
<button type="submit" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete selected
</button>
{% endif %}
{% if perms.dcim.add_devicebay %}
<div class="pull-right">
<a href="{% url 'dcim:devicebay_add' pk=device.pk %}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add device bays
</a>
</div>
<div class="col-md-6 text-right">
{% if perms.dcim.add_devicebay %}
<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 bay
</a>
{% endif %}
</div>
</div>
<div class="clearfix"></div>
{% endif %}
</div>
{% endif %}
</div>
@@ -350,12 +380,24 @@
{% endif %}
{% if interfaces or device.device_type.is_network_device %}
{% if perms.dcim.delete_interface %}
<form method="post" action="{% url 'dcim:interface_bulk_delete' pk=device.pk %}">
<form method="post">
{% csrf_token %}
{% endif %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>Interfaces</strong>
<div class="pull-right">
{% 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
</a>
{% endif %}
</div>
</div>
<table class="table table-hover panel-body">
{% for iface in interfaces %}
@@ -368,23 +410,24 @@
</table>
{% if perms.dcim.add_interface or perms.dcim.delete_interface %}
<div class="panel-footer">
<div class="row">
<div class="col-md-6">
{% if interfaces and perms.dcim.delete_interface %}
<button type="submit" class="btn btn-xs btn-danger">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Selected
</button>
{% endif %}
{% if interfaces and perms.dcim.change_interface %}
<button type="submit" name="_edit" formaction="{% url 'dcim:interface_bulk_edit' pk=device.pk %}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit selected
</button>
{% endif %}
{% if interfaces and perms.dcim.delete_interface %}
<button type="submit" name="_delete" formaction="{% url 'dcim:interface_bulk_delete' pk=device.pk %}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete selected
</button>
{% endif %}
{% if perms.dcim.add_interface %}
<div class="pull-right">
<a href="{% url 'dcim:interface_add' pk=device.pk %}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
</a>
</div>
<div class="col-md-6 text-right">
{% if perms.dcim.add_interface %}
<a href="{% url 'dcim:interface_add' pk=device.pk %}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
Add interface
</a>
{% endif %}
</div>
</div>
<div class="clearfix"></div>
{% endif %}
</div>
{% endif %}
</div>
@@ -400,6 +443,18 @@
<div class="panel panel-default">
<div class="panel-heading">
<strong>Console Server Ports</strong>
<div class="pull-right">
{% 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
</a>
{% endif %}
</div>
</div>
<table class="table table-hover panel-body">
{% for csp in cs_ports %}
@@ -412,23 +467,19 @@
</table>
{% if perms.dcim.add_consoleserverport or perms.dcim.delete_consoleserverport %}
<div class="panel-footer">
<div class="row">
<div class="col-md-6">
{% if cs_ports and perms.dcim.delete_consoleserverport %}
<button type="submit" class="btn btn-xs btn-danger">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Selected
</button>
{% endif %}
{% if cs_ports and perms.dcim.delete_consoleserverport %}
<button type="submit" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete selected
</button>
{% endif %}
{% if perms.dcim.add_consoleserverport %}
<div class="pull-right">
<a href="{% url 'dcim:consoleserverport_add' pk=device.pk %}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console server ports
</a>
</div>
<div class="col-md-6 text-right">
{% if perms.dcim.add_consoleserverport %}
<a href="{% url 'dcim:consoleserverport_add' pk=device.pk %}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
Add console server ports
</a>
{% endif %}
</div>
</div>
<div class="clearfix"></div>
{% endif %}
</div>
{% endif %}
</div>
@@ -444,6 +495,18 @@
<div class="panel panel-default">
<div class="panel-heading">
<strong>Power Outlets</strong>
<div class="pull-right">
{% 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
</a>
{% endif %}
</div>
</div>
<table class="table table-hover panel-body">
{% for po in power_outlets %}
@@ -456,23 +519,19 @@
</table>
{% if perms.dcim.add_poweroutlet or perms.dcim.delete_poweroutlet %}
<div class="panel-footer">
<div class="row">
<div class="col-md-6">
{% if power_outlets and perms.dcim.delete_poweroutlet %}
<button type="submit" class="btn btn-xs btn-danger">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Selected
</button>
{% endif %}
{% if power_outlets and perms.dcim.delete_poweroutlet %}
<button type="submit" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete selected
</button>
{% endif %}
{% if perms.dcim.add_poweroutlet %}
<div class="pull-right">
<a href="{% url 'dcim:poweroutlet_add' pk=device.pk %}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power outlets
</a>
</div>
<div class="col-md-6 text-right">
{% if perms.dcim.add_poweroutlet %}
<a href="{% url 'dcim:poweroutlet_add' pk=device.pk %}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
Add power outlets
</a>
{% endif %}
</div>
</div>
<div class="clearfix"></div>
{% endif %}
</div>
{% endif %}
</div>
@@ -531,13 +590,13 @@ function toggleConnection(elem, api_url) {
return false;
}
$(".consoleport-toggle").click(function() {
return toggleConnection($(this), "/api/dcim/console-ports/");
return toggleConnection($(this), "/{{ settings.BASE_PATH }}api/dcim/console-ports/");
});
$(".powerport-toggle").click(function() {
return toggleConnection($(this), "/api/dcim/power-ports/");
return toggleConnection($(this), "/{{ settings.BASE_PATH }}api/dcim/power-ports/");
});
$(".interface-toggle").click(function() {
return toggleConnection($(this), "/api/dcim/interface-connections/");
return toggleConnection($(this), "/{{ settings.BASE_PATH }}api/dcim/interface-connections/");
});
</script>
<script src="{% static 'js/graphs.js' %}"></script>

View File

@@ -0,0 +1,60 @@
{% extends '_base.html' %}
{% load form_helpers %}
{% block content %}
<h1>Add {{ component_name|title }}</h1>
<form action="." method="post" class="form form-horizontal">
{% csrf_token %}
{% if request.POST.redirect_url %}
<input type="hidden" name="redirect_url" value="{{ request.POST.redirect_url }}" />
{% endif %}
{% for field in form.hidden_fields %}
{{ field }}
{% endfor %}
<div class="row">
<div class="col-md-7">
<div class="panel panel-default">
<div class="panel-heading"><strong>Selected Devices</strong></div>
<table class="panel-body table table-hover">
<tr>
<th>Device</th>
<th>Type</th>
<th>Role</th>
</tr>
{% for device in selected_devices %}
<tr>
<td><a href="{% url 'dcim:device' pk=device.pk %}">{{ device }}</a></td>
<td>{{ device.device_type.full_name }}</td>
<td>{{ device.device_role }}</td>
</tr>
{% endfor %}
</table>
</div>
</div>
<div class="col-md-5">
{% 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>{{ component_name|title }} to Add</strong></div>
<div class="panel-body">
{% for field in form.visible_fields %}
{% render_field field %}
{% endfor %}
</div>
</div>
<div class="form-group text-right">
<div class="col-md-12">
<button type="submit" name="_create" class="btn btn-primary">Create</button>
<a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
</div>
</div>
</div>
</div>
</form>
{% endblock %}

View File

@@ -3,11 +3,18 @@
{% block title %}Device Bulk Edit{% endblock %}
{% block select_objects_table %}
{% block selected_objects_table %}
<tr>
<th>Device</th>
<th>Type</th>
<th>Role</th>
<th>Tenant</th>
<th>Serial</th>
</tr>
{% 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 %}{% if module %}Editing {{ module.device }} {{ module }}{% else %}Add a Module to {{ device }}{% endif %}{% 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>{% if module %}Editing {{ module.device }} {{ module }}{% else %}Add a Module to {{ device }}{% endif %}</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">{% if module %}{{ module.device }}{% else %}{{ device }}{% endif %}</p>
<p class="form-control-static">{{ parent }}</p>
</div>
</div>
{% render_form form %}
@@ -32,12 +32,8 @@
</div>
<div class="form-group">
<div class="col-md-9 col-md-offset-3">
{% if module.pk %}
<button type="submit" name="_update" class="btn btn-primary">Save</button>
{% else %}
<button type="submit" name="_create" class="btn btn-primary">Create</button>
<button type="submit" name="_addanother" class="btn btn-primary">Create and Add More</button>
{% endif %}
<button type="submit" name="_create" class="btn btn-primary">Create</button>
<button type="submit" name="_addanother" class="btn btn-primary">Create and Add More</button>
<a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
</div>
</div>

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

@@ -78,7 +78,7 @@
</tr>
<tr>
<td>Position (U)</td>
<td>Lowest rack unit occupied by the device (optional)</td>
<td>Lowest-numbered rack unit occupied by the device (optional)</td>
<td>21</td>
</tr>
<tr>

View File

@@ -10,10 +10,10 @@
<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>
@@ -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

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

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