Compare commits

...

394 Commits

Author SHA1 Message Date
Jeremy Stretch
e0ad2b4555 Merge pull request #1054 from digitalocean/develop
Release v1.9.5
2017-04-06 16:35:15 -04:00
Jeremy Stretch
35a0a658a7 Release v1.9.5 2017-04-06 16:34:00 -04:00
Jeremy Stretch
2c99a8bee4 Closes #1052: Added rack reservation list and bulk delete views 2017-04-06 16:26:48 -04:00
Jeremy Stretch
1dd2bdcb8e Fixes #1047: Correct ordering of numbered subinterfaces 2017-04-06 15:13:20 -04:00
Jeremy Stretch
f3eee25527 Fixes #1051: Upgraded django-rest-swagger 2017-04-06 11:54:13 -04:00
Jeremy Stretch
78b0072051 Limit <v2.0 installations to Django 1.10 2017-04-05 11:34:04 -04:00
Jeremy Stretch
7766e1f684 Fixes #1037: Fixed error on VLAN import with duplicate VLAN group names 2017-04-05 10:13:19 -04:00
Jeremy Stretch
78adaecb89 Post-release version bump 2017-04-04 15:50:59 -04:00
Jeremy Stretch
f89d91783b Merge pull request #1035 from digitalocean/develop
Release v1.9.4-r1
2017-04-04 15:50:28 -04:00
Jeremy Stretch
a18e1a0161 Release v1.9.4-r1 2017-04-04 15:47:25 -04:00
Jeremy Stretch
4308b8a4a5 Fixes #1034: Missing migration 2017-04-04 15:46:27 -04:00
Jeremy Stretch
aa54e14c37 Post-release version bump 2017-04-04 12:03:26 -04:00
Jeremy Stretch
3ffe36e5ed Merge pull request #1032 from digitalocean/develop
Release v1.9.4
2017-04-04 12:01:58 -04:00
Jeremy Stretch
3b2c74042e Release v1.9.4 2017-04-04 11:58:44 -04:00
Jeremy Stretch
11ae938146 Fixes #1027: Fixed nav menu highlighting when BASE_PATH is set 2017-04-04 11:55:16 -04:00
Stephen
f11bb254a5 Only show Custom Fields on IP Address Assign Page if custom fields are set against the ip address (#1031) 2017-04-04 11:37:20 -04:00
Jeremy Stretch
0b681c471e Removed survey notice 2017-04-03 16:01:03 -04:00
Jeremy Stretch
05d3354570 Fixes #1022: Record user actions when creating IP addresses in bulk 2017-04-03 14:45:20 -04:00
Jeremy Stretch
6813787fc7 Fixes #1013: Show edit/delete reservation buttons on rack view 2017-03-29 12:15:14 -04:00
Jeremy Stretch
28761fc960 Closes #362: Added per_page query parameter to control pagination page length 2017-03-28 15:57:50 -04:00
Jeremy Stretch
e8fd0f3531 Order interfaces naturally for Device A 2017-03-27 10:55:54 -04:00
Jeremy Stretch
8103c399d5 Fixes #991: Correct server error on "create and connect another" interface connection 2017-03-27 10:53:32 -04:00
Jeremy Stretch
a51f5edbc8 Post-release version bump 2017-03-23 16:29:42 -04:00
Jeremy Stretch
be393a9d10 Merge pull request #989 from digitalocean/develop
Release v1.9.3
2017-03-23 16:27:06 -04:00
Jeremy Stretch
ef59f38ec4 Release v1.9.3 2017-03-23 16:24:35 -04:00
Jeremy Stretch
47120fae01 Rack assignment is optional for devices 2017-03-23 15:36:24 -04:00
Jeremy Stretch
c0417c1989 Closes #972: Add ability to filter connections list by device name 2017-03-23 10:07:02 -04:00
Jeremy Stretch
fb6cfa45fd Merge pull request #974 from marc-us/develop
Filter on mac address on interface
2017-03-23 09:35:01 -04:00
Mark
b875cea10d Filter on mac address on interface via API 2017-03-23 12:57:35 +01:00
Jeremy Stretch
32bf17c076 Closes #978: Allow filtering device types by function and subdevice role 2017-03-22 17:29:47 -04:00
Jeremy Stretch
66a6a8f33c Closes #983: Include peer device names when listing circuits in device view 2017-03-22 16:58:56 -04:00
Jeremy Stretch
05b71564d8 Closes #981: Allow filtering primary objects by a given set of IDs 2017-03-22 09:39:30 -04:00
Jeremy Stretch
1682de59df Added a footer link to the GitHub wiki 2017-03-20 14:05:26 -04:00
Mark
f26253ec49 Filter on mac address on interface 2017-03-18 21:26:33 +01:00
Mark
f2dc287f14 Filter on mac address on interface 2017-03-18 21:21:49 +01:00
Mark
3fe3151af7 Filter on mac address on interface
Extension to be able filter on mac address via API
2017-03-18 21:10:36 +01:00
Jeremy Stretch
1c1fd8f210 Limit tests to one per major Python version 2017-03-17 21:43:46 -04:00
Jeremy Stretch
3ce2f0d100 Fix error when assigning a new interface to a LAG 2017-03-16 22:27:01 -04:00
Jeremy Stretch
92d726bbd4 Added examples to the graphs documentation 2017-03-15 12:16:46 -04:00
Jeremy Stretch
e2ef0bc3a6 Added survey announcement to README 2017-03-15 12:00:53 -04:00
Jeremy Stretch
13c29cb7a9 Post-release version bump 2017-03-14 17:18:05 -04:00
Jeremy Stretch
27eefd8705 Merge pull request #966 from digitalocean/develop
Release v1.9.2
2017-03-14 17:14:19 -04:00
Jeremy Stretch
b22c6a0078 Release v1.9.2 2017-03-14 17:07:04 -04:00
Jeremy Stretch
f4784412de Fixes #964: Fix bug when bulk editing/deleting filtered set of objects 2017-03-14 15:22:08 -04:00
Jeremy Stretch
33c5ea1f4e Fixes #963: Fix bug in IPv6 address range expansion 2017-03-14 15:06:34 -04:00
Jeremy Stretch
d9f1bcbf15 Renamed user URL namespace 2017-03-14 12:36:44 -04:00
Jeremy Stretch
4b7af8d3b4 Merge pull request #954 from psr/develop
Force Unix line endings on shell scripts
2017-03-13 11:35:05 -04:00
Jeremy Stretch
f3fd82a24a Allow assigning child devices to rackless parents 2017-03-13 11:31:28 -04:00
Jeremy Stretch
cd97b2fb96 Fix parent device position display 2017-03-13 11:25:06 -04:00
Jeremy Stretch
f661c233be Fixes #950: Fix site_id error on child device import 2017-03-13 11:18:33 -04:00
Jeremy Stretch
6a2a2d5d11 Fixes #957: Correct device site filter count to include unracked devices 2017-03-13 10:13:04 -04:00
Jeremy Stretch
87ff433ef8 Fixes #956: Correct bug affecting unnamed rackless devices 2017-03-13 10:06:32 -04:00
Peter Russell
d68b34cefe Force Unix line endings on shell scripts
When following the quickstart docker instructions on Windows (using
Docker for Windows), an error is encountered when running the netbox
container. This is caused by git converting the line endings of the
docker-entrypoint.sh script to Windows format, which are then copied
into the container image.

By setting .gitattributes, we force LF rather than CRLF line endings on
shell scripts on Windows. Other files are left as is.
2017-03-09 16:20:32 +00:00
Jeremy Stretch
70a05b4280 Post-release version bump 2017-03-08 14:45:23 -05:00
Jeremy Stretch
097e0f38ff Merge pull request #949 from digitalocean/develop
Release v1.9.1
2017-03-08 14:40:16 -05:00
Jeremy Stretch
094974d417 Release v1.9.1 2017-03-08 14:38:24 -05:00
Jeremy Stretch
d89314a559 Fixes #943: Child prefixes missing on Python 3 2017-03-08 14:10:27 -05:00
Jeremy Stretch
086340540a Fixes #948: Region name should be hyperlinked to site list 2017-03-08 12:04:50 -05:00
Jeremy Stretch
ed83b1d9e9 Closes #946: Disregard mask length when filtering IP addresses by a parent prefix 2017-03-08 11:59:24 -05:00
Jeremy Stretch
4e766c7c3b Closes #945: Display the current user in the nav menu 2017-03-07 22:27:46 -05:00
Jeremy Stretch
4b753b1610 Fixes #944: Correct console and power connection form behavior 2017-03-07 13:32:48 -05:00
Jeremy Stretch
f8381628d4 Fixes #941: Corrected old references to rack.site on Device 2017-03-06 10:23:24 -05:00
Jeremy Stretch
d8f41f67c9 Post-release version bump 2017-03-03 11:28:27 -05:00
Jeremy Stretch
ce26b566a4 Merge pull request #939 from digitalocean/develop
Release v1.9.0-r1
2017-03-03 11:28:02 -05:00
Jeremy Stretch
ee2d0b963d Release v1.9.0-r1 2017-03-03 11:26:31 -05:00
Jeremy Stretch
f051c0e564 Fixes #935: Fix form validation error when connecting an interface using live search 2017-03-03 11:19:19 -05:00
Jeremy Stretch
03d3bbcddb Fixes #938: Provider view yields an error if one or more circuits is assigned to a tenant 2017-03-03 10:47:16 -05:00
Jeremy Stretch
06cafb09b3 Fixes #937: Region assignment should be optional when creating a site 2017-03-03 10:44:07 -05:00
Jeremy Stretch
4670929953 Added GitHub issue/PR templates; updated CONTRIBUTING policy 2017-03-02 15:30:14 -05:00
Jeremy Stretch
366e2e7a94 Post-release version bump 2017-03-02 13:30:45 -05:00
Jeremy Stretch
0e14bc1e02 Merge pull request #933 from digitalocean/develop
Release v1.9.0
2017-03-02 13:27:10 -05:00
Jeremy Stretch
e5f05ca9be Release v1.9.0 2017-03-02 13:22:29 -05:00
Jeremy Stretch
d08522408a Documentation refresh 2017-03-02 13:21:56 -05:00
Jeremy Stretch
cc31c8fc33 Closes #894: Expand device name max length to 64 characters 2017-03-02 10:11:54 -05:00
Jeremy Stretch
db60e8868c Closes #901: Support for filtering prefixes and IP addresses by mask length 2017-03-01 14:23:52 -05:00
Jeremy Stretch
1adae67dd7 Closes #927: Upgrade to django-filter 1.0 2017-03-01 13:09:19 -05:00
Jeremy Stretch
5ad3044314 Closes #862: Show both IPv6 and IPv4 primary IPs in device list 2017-02-28 16:46:44 -05:00
Jeremy Stretch
9313ba08ed Implemented recursive regions with django-mptt 2017-02-28 14:15:15 -05:00
Jeremy Stretch
f3b9930dea Initial work on regions 2017-02-28 12:11:43 -05:00
Jeremy Stretch
5520144ff4 Fixed permissions for inclusion of interface bulk edit form 2017-02-27 17:19:30 -05:00
Jeremy Stretch
2b9ea58c86 Tweaked interface LAG validation 2017-02-27 17:13:58 -05:00
Jeremy Stretch
c6970e1998 Closes #105: Interface groups (#919)
* Initial work on interface groups

* Simplify to a single LAG form factor

* Correct interface serializer

* Allow for bulk editing of interface LAG

* Additional LAG interface validation

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

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

* commit1

* commit2

* commit3

* Add migration file for VLAN&VLAN group

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

* Updated device component connection forms

* Updated IP address assignment form

* Updated circuit termination form

* Formatting cleanup

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

* Added views for rack reservations

* Implemented ArrayFieldSelectMultiple form widget

* Implemented API endpoints for rack reservations

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

* fixed PEP8 style

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

* update docs and trigger Travis

* fix some of the tests

* fix all python3 errors

* change env calls to just python

* add @python_2_unicode_compatible decorator to models for python2 compatibility

* switch netbox.configuration to from netbox import configuration
2017-01-23 16:44:29 -05:00
Jeremy Stretch
1bddd038fe Fixes #840: Correct API path resolution for secrets when BASE_PATH is configured 2017-01-23 16:25:05 -05:00
Jeremy Stretch
d36923e47d Fixes #817: Update last_updated time of a circuit when editing a child termination 2017-01-23 15:31:41 -05:00
Jeremy Stretch
476cbf17f6 Closes #820: Add VLAN column to parent prefixes table on IP address view 2017-01-23 14:23:42 -05:00
Jeremy Stretch
91d50b9627 Closes #836: Add 'deprecated' status for IP addresses 2017-01-23 14:12:43 -05:00
Jeremy Stretch
52420945b2 Standardized naming of return_url for all object views 2017-01-23 14:07:26 -05:00
Jeremy Stretch
b70eca7661 Fixes #830: Redirect user to device view after editing a device component 2017-01-23 12:14:12 -05:00
Jeremy Stretch
39d083eae7 Re-implemented method for bulk editing/deleting all objects within a filtered queryset 2017-01-20 16:42:11 -05:00
Jeremy Stretch
3bfc1ebcea Post-release version bump 2017-01-18 16:23:52 -05:00
Jeremy Stretch
b6bbcb0609 Merge pull request #814 from digitalocean/develop
Release v1.8.2
2017-01-18 16:23:28 -05:00
Jeremy Stretch
6121f97ca9 Release v1.8.2 2017-01-18 16:19:45 -05:00
Jeremy Stretch
74e48fc490 PEP8 fixes 2017-01-18 14:43:46 -05:00
Jeremy Stretch
28a9307f9f Deprecated use_obj_view in favor of get_return_url() 2017-01-18 14:34:17 -05:00
Jeremy Stretch
cdccc3a47f Ditched get_parent_url() model method in favor of overrideable get_return_url() view method 2017-01-18 14:07:46 -05:00
Jeremy Stretch
3eb969de0c Standardized the use of return_url for ObjectDeleteView 2017-01-18 13:30:19 -05:00
Jeremy Stretch
9ff59ab686 Closes #760: Redirect user back to device view after deleting an assigned IP address 2017-01-18 12:25:07 -05:00
Jeremy Stretch
fc7f88d2a2 Regression fix: order_naturally() must come first in the queryset definition 2017-01-18 11:55:48 -05:00
Jeremy Stretch
769537fe98 Fixes #810: Suppress unique IP validation on invalid IP addresses and prefixes 2017-01-18 09:55:57 -05:00
Jeremy Stretch
f8a4f1b24f Closes #797: Add description column to VLANs table 2017-01-17 16:06:19 -05:00
Jeremy Stretch
7f3b358571 Fixes #807: Redirect user back to form when adding IP addresses in bulk and "create and add another" is clicked 2017-01-17 15:46:43 -05:00
Jeremy Stretch
c264281530 Add an empty label (global) to IPAddressBulkAddForm VRF field 2017-01-17 15:33:55 -05:00
Jeremy Stretch
b3f20aa233 Closes #783: Add a description field to the Circuit model 2017-01-17 15:18:03 -05:00
Jeremy Stretch
07997b24ca Fixes #785: Trigger validation error when importing a prefix assigned to a nonexistent VLAN 2017-01-17 15:01:30 -05:00
Jeremy Stretch
03859d7287 Closes #803: Clarify that no child objects are deleted when deleting a prefix 2017-01-17 14:52:39 -05:00
Jeremy Stretch
0ad2670822 Closes #805: Linkify site column in device table 2017-01-17 14:46:29 -05:00
Jeremy Stretch
ab706d2440 Follow-up to #804 2017-01-17 12:32:54 -05:00
Jeremy Stretch
398faf518c Merge pull request #804 from digitalocean/prefix-unique
Enforce Global Unique on Prefixes
2017-01-17 10:28:00 -05:00
Zach Moody
edf29e7b9b moved duplicates() method to model instead of manager. 2017-01-16 18:14:34 -06:00
Zach Moody
485a21f13e cleaned up IPAddress clean() to be more like Prefix's 2017-01-16 16:52:03 -06:00
Zach Moody
eedec192ba Added model tests for duplicate prefix and IPs. 2017-01-16 16:40:06 -06:00
Zach Moody
cfaf8b9157 added duplicates() method to IPAddress and Prefix model managers.
refactored condition on IPAddress and Prefix clean method to use new
manager method.
2017-01-16 16:28:04 -06:00
Jeremy Stretch
98e2145b52 Merge pull request #796 from rburkholder/rburkholder-patch-TenantSerializer-description
rburkholder - patch - tenant serialiser - add description field to output
2017-01-13 09:36:33 -05:00
Jeremy Stretch
466c505bb8 Corrected PEP8 errors 2017-01-13 09:30:59 -05:00
Raymond P. Burkholder
97c0f23c67 Add description field to TenantSerializer
This might be just an oversight.  Other data models do include the description in their serialisers.  The API produces the description field with this change.
2017-01-13 08:49:43 -04:00
Jeremy Stretch
424c2a59d6 Table rendering optimizations 2017-01-06 16:50:57 -05:00
Jeremy Stretch
c9e7c12463 Closes #284: Added interface_ordering field to DeviceType 2017-01-06 12:59:49 -05:00
Jeremy Stretch
2ef1e623a3 Merge pull request #781 from zevlag/patch-1
Update CONTRIBUTING.md to replace Reddit with Google Groups
2017-01-05 21:55:16 -05:00
Josh Galvez
1486a8901a Update CONTRIBUTING.md
Change Reddit to Google Groups Mailing List
2017-01-05 16:02:05 -07:00
Jeremy Stretch
73ae87aa57 Updated circuits documentation 2017-01-05 17:06:30 -05:00
Jeremy Stretch
ac72e90dcc Fixes #778: Refactored order_interfaces() to fix InterfaceTemplate ordering within a table 2017-01-05 16:12:07 -05:00
Jeremy Stretch
dbf9840b26 Corrected permissions for device component form rendering 2017-01-05 15:37:15 -05:00
Jeremy Stretch
09fe328c3f Standardized template names 2017-01-05 15:31:41 -05:00
Jeremy Stretch
381eb664cf Added alternative installations section 2017-01-04 17:16:04 -05:00
Jeremy Stretch
23c6451524 Fixes #776: Prevent circuits from appearing twice while searching 2017-01-04 16:56:28 -05:00
Jeremy Stretch
99cd78cbbf Post-release version bump 2017-01-04 15:31:10 -05:00
Jeremy Stretch
23f6832d9c Merge pull request #774 from digitalocean/develop
Release v1.8.1
2017-01-04 15:30:54 -05:00
Jeremy Stretch
bce23ebdf5 Release v1.8.1 2017-01-04 15:28:53 -05:00
Jeremy Stretch
0d4b2a6e92 Fixes #772: Fix TypeError in API RackUnitListView when no device is excluded 2017-01-04 10:58:11 -05:00
Jeremy Stretch
52567c4ade Fixes #764: Encapsulate in double quotes values containing commas when exporting to CSV 2017-01-04 10:47:00 -05:00
Jeremy Stretch
8154ae3685 Closes #771: Don't automatically redirect user when only one object is returned in a list 2017-01-04 09:51:40 -05:00
Jeremy Stretch
7f297b4733 Fixes #769: Show default value for boolean custom fields 2017-01-04 09:47:26 -05:00
Jeremy Stretch
96451bfe9e Fixes #767: Fixes xconnect_id error when searching for ciruits 2017-01-03 17:00:43 -05:00
Jeremy Stretch
921b08d0c9 Allow null filtering of prefix VLAN 2017-01-03 16:57:42 -05:00
Jeremy Stretch
6eff95a2b1 Post-release version bump 2017-01-03 15:14:09 -05:00
Jeremy Stretch
88dace75a1 Merge pull request #766 from digitalocean/develop
Release v1.8.0
2017-01-03 15:13:36 -05:00
Jeremy Stretch
f8bced34eb Release v1.8.0 2017-01-03 15:07:41 -05:00
Jeremy Stretch
cf64ef342f Fixes #763: Added missing fields to CSV exports 2017-01-03 14:52:56 -05:00
Jeremy Stretch
c7acc9ad69 Updated circuit import template 2017-01-03 14:25:51 -05:00
Jeremy Stretch
31e8986e35 Minor docs tweaks 2017-01-03 14:03:50 -05:00
Jeremy Stretch
050b6449d4 Split site contact info into a separate panel 2017-01-03 13:46:49 -05:00
Jeremy Stretch
49dd5761f8 Tweaked web server installation docs 2017-01-03 13:44:22 -05:00
Jeremy Stretch
5215779061 Fixes #757: Debug toolbar middleware must always be included, even if DEBUG is False 2016-12-29 13:45:57 -05:00
Jeremy Stretch
48e9cd6a00 Miscellaneous cleanup and documentation 2016-12-29 13:42:38 -05:00
Jeremy Stretch
e06bfffd60 Fixed outdated select_related reference to circuit 2016-12-29 11:53:24 -05:00
Jeremy Stretch
e7b08f8f2f Closes #756: Added contact details to site model 2016-12-29 11:37:40 -05:00
Jeremy Stretch
8edaff860c Fixes #658: Added is_pool field to Prefix model 2016-12-27 15:07:52 -05:00
Jeremy Stretch
d9d7068c5f Fixed bug introduced in 04fd197c9b 2016-12-27 14:18:31 -05:00
Jeremy Stretch
e647065e63 Improved device interface list performance 2016-12-27 13:21:19 -05:00
Jeremy Stretch
5716207ba6 Simplified paginator when dealing with <=5 pages 2016-12-26 15:43:48 -05:00
Jeremy Stretch
bdff71db9e Fixed paginator text rendering 2016-12-26 15:36:33 -05:00
Jeremy Stretch
9e670d318c Relaxed version requirements 2016-12-26 15:33:08 -05:00
Jeremy Stretch
1882d832c3 Bumped django-tables2 version 2016-12-26 14:34:13 -05:00
Jeremy Stretch
04fd197c9b Fixed table form rendering for django-tables2>=1.2.1 2016-12-26 14:30:56 -05:00
Jeremy Stretch
edb8904474 Fixed debug toolbar display 2016-12-26 12:15:14 -05:00
Jeremy Stretch
a5fe4468d0 Upgraded django-filter to 0.15.3 2016-12-26 11:58:27 -05:00
Jeremy Stretch
65d8bb8c26 Bumped Markdown version 2016-12-26 11:05:46 -05:00
Jeremy Stretch
cf796fb40f Fixes #751: Relax version constraint on python-cryptography 2016-12-26 10:55:14 -05:00
Jeremy Stretch
0ac3e91e3b Updated middleware for Django 1.10 2016-12-26 10:48:15 -05:00
Jeremy Stretch
e8684240a7 Added permissions evaluation 2016-12-21 17:28:35 -05:00
Jeremy Stretch
c1b6da771f Only display "select all" button if there are two or more items 2016-12-21 17:26:25 -05:00
Jeremy Stretch
3de51876d0 Refactored device component creation views 2016-12-21 17:20:27 -05:00
Jeremy Stretch
0e4d02bd10 Renamed template 2016-12-21 15:31:55 -05:00
Jeremy Stretch
7b06f5e9fc Introduced DeviceComponentCreateView 2016-12-21 15:26:56 -05:00
Jeremy Stretch
37b2ff02e7 Standardized inheritance order of BootstrapMixin 2016-12-21 14:15:18 -05:00
Jeremy Stretch
1ed5389703 Fixed device component bulk creation permissions 2016-12-21 13:52:16 -05:00
Jeremy Stretch
b6da5ce6bd Fixed device type component creation permissions 2016-12-21 13:47:15 -05:00
Jeremy Stretch
ae8f40ed8d Fixes #563: Allow a device to be flipped from one rack face to the other 2016-12-21 11:06:42 -05:00
Jeremy Stretch
96de61ddfb Closes #716: Add ASN field to site bulk edit form 2016-12-20 16:13:45 -05:00
Jeremy Stretch
9fd9719d0b Closes #181: Implemented support for bulk IP address creation 2016-12-20 15:39:22 -05:00
Jeremy Stretch
f0d8e02d63 Fixed prefix/VLAN role links 2016-12-19 14:45:25 -05:00
Jeremy Stretch
44d5ff26a4 Fixes #747: Fixes natural_order_by integer cast error on large numbers 2016-12-19 11:01:44 -05:00
Jeremy Stretch
550efcb640 Tweaked prefix column header padding 2016-12-16 17:09:27 -05:00
Jeremy Stretch
15bec75167 Fixes #744: Fixed export of sites without an AS number 2016-12-16 16:33:07 -05:00
Jeremy Stretch
c94d111401 Closes #743: Enabled bulk creation of all device components 2016-12-16 16:29:32 -05:00
Jeremy Stretch
6f1532adac Fixed dcim tests 2016-12-16 12:15:32 -05:00
Jeremy Stretch
b7fe220860 Converted module_add to ObjectEditView 2016-12-16 12:12:42 -05:00
Jeremy Stretch
b451ece057 Closes #122: Add comments field to device types 2016-12-16 11:14:44 -05:00
Jeremy Stretch
b56e37ad84 Closes #722: Enabled custom fields for device types 2016-12-16 10:54:45 -05:00
Jeremy Stretch
712567cabc Closes #613: Added prefixes column to VLAN list; added VLAN column to prefix list 2016-12-15 16:56:43 -05:00
Jeremy Stretch
017263f640 Fixes #741: Hide "select all" button for users without edit permissions 2016-12-15 15:58:50 -05:00
Jeremy Stretch
f02c222d4f Closes #539: Implemented L4 services for devices 2016-12-15 15:32:58 -05:00
Jeremy Stretch
66fa877198 ObjectEditView: Save many-to-many fields 2016-12-15 14:37:35 -05:00
Jeremy Stretch
6a9f26a68d Cleaned up attribute tables 2016-12-14 17:09:33 -05:00
Jeremy Stretch
bf817eb69e Closes #49: Introduction of circuit terminations 2016-12-14 13:47:22 -05:00
Jeremy Stretch
298ac1ba7a Widened page layout; improved mobile rendering 2016-12-09 16:23:11 -05:00
Jeremy Stretch
bd40f72ad5 #49: Allow selection of devices at other sites when connecting an interface 2016-12-09 11:43:50 -05:00
Jeremy Stretch
a0eff04185 Post-release version bump 2016-12-08 12:35:18 -05:00
Jeremy Stretch
8eb140fd65 Merge pull request #736 from digitalocean/develop
Release v1.7.3
2016-12-08 12:34:53 -05:00
Jeremy Stretch
a68e82575f Release v1.7.3 2016-12-08 12:33:36 -05:00
Jeremy Stretch
5035a9567b Fixes #729: Corrected cancellation links when editing secondary objects 2016-12-08 12:20:45 -05:00
Jeremy Stretch
d5095362d7 Fixes #734: Corrected display of device type when editing a device 2016-12-08 09:59:21 -05:00
Jeremy Stretch
3a6d7a1f7f #733: Fixed MAC address device filter 2016-12-07 15:53:19 -05:00
Jeremy Stretch
cc6ae8ebe4 Merge pull request #733 from linuxsimba/remove_mac_addr_required_from_filter
FIX: filtering devices fails because mac address filter is a required
2016-12-07 15:42:50 -05:00
stanley karunditu
b4940a64be FIX: filtering devices fails because mac address filter is a required
field
2016-12-07 15:30:57 -05:00
Jeremy Stretch
fca812928e #724: Exempt API views from LoginRequiredMiddleware to enable basic HTTP authentication when LOGIN_REQUIRED is true 2016-12-07 15:14:22 -05:00
Jeremy Stretch
4a9b4c5387 Fixes #732: Allow custom select field values to be deselected if the field is not required 2016-12-07 14:00:52 -05:00
Jeremy Stretch
1f09f3d096 Merge pull request #728 from digitalocean/develop
Release v1.7.2-r1
2016-12-06 15:38:52 -05:00
Jeremy Stretch
efb95937fc Reverting GitHub test 2016-12-06 15:32:11 -05:00
Jeremy Stretch
ce7ee1771a Testing GitHub 2016-12-06 15:31:31 -05:00
Jeremy Stretch
da216e2c22 Fixes #727: Corrected error in rack elevation display 2016-12-06 15:27:35 -05:00
Jeremy Stretch
e58ee4e0e3 Post-release version bump 2016-12-06 14:55:45 -05:00
Jeremy Stretch
66be85a41f Merge pull request #726 from digitalocean/develop
Release v1.7.2
2016-12-06 14:55:19 -05:00
Jeremy Stretch
2171dcee7f Release v1.7.2 2016-12-06 14:53:43 -05:00
Jeremy Stretch
3262262a8a Closes #663: Added MAC address search field to device list 2016-12-06 14:45:01 -05:00
Jeremy Stretch
28b586aca7 Fixes #723: API documentation is now accessible when using BASE_PATH 2016-12-06 14:08:25 -05:00
Jeremy Stretch
f007b0dbde Closes #695: Added is_private field to RIR 2016-12-06 13:59:13 -05:00
Jeremy Stretch
6e5950be77 Fixes #720: Display user action links properly in admin UI 2016-12-06 13:16:42 -05:00
Jeremy Stretch
eb4cd0e723 Fixes #672: Expanded color selection for rack and device roles 2016-12-06 12:28:29 -05:00
Jeremy Stretch
300ee820fa #672: Cleaned up rack elevation CSS 2016-12-05 18:11:07 -05:00
Jeremy Stretch
7d6d7942d9 Rewrote get_connected_interface() to return first connection if more than one exist (erroneously) 2016-12-02 16:09:07 -05:00
Jeremy Stretch
05debf7e40 Updated CONTRIBUTING to discourage the use of "+1" comments on issues 2016-12-01 15:16:04 -05:00
Jeremy Stretch
dc88cb5ac7 Fixes #718: Restore is_primary field on IP assignment form 2016-12-01 14:54:20 -05:00
Jeremy Stretch
b275009544 Fixed missing on command block 2016-11-30 12:10:46 -05:00
Jeremy Stretch
d960481adb Ditched syntax highlighting for shell commands 2016-11-30 12:07:51 -05:00
Jeremy Stretch
2986840755 Specified syntax for code blocks 2016-11-30 12:01:45 -05:00
Jeremy Stretch
9b8bae501b Fixes #677: Add cffi as an explicit dependency to avoid setuptools error on Debian 2016-11-30 11:19:28 -05:00
Jeremy Stretch
9ea3383fde #702: Fix lingering Unicode incompatibility 2016-11-29 17:33:22 -05:00
Jeremy Stretch
77ac79f32c Fixes #713: Include a label for the comments field when editing circuits, providers, or racks in bulk 2016-11-29 17:29:56 -05:00
Jeremy Stretch
e31fae5ec5 Fixes #712: Corrected export of tenants which are not assigned to a group 2016-11-29 13:45:31 -05:00
Jeremy Stretch
8bff8bcbe2 Fixes #702: Improved Unicode support for custom fields 2016-11-29 13:34:22 -05:00
Jeremy Stretch
cc79b1136b Fixes #696: Corrected link to VRF in prefix and IP address breadcrumbs 2016-11-18 09:49:04 -05:00
Jeremy Stretch
1af9ea9e2d Post-release version bump 2016-11-15 12:36:17 -05:00
Jeremy Stretch
814c11167e Merge pull request #694 from digitalocean/develop
Release v1.7.1
2016-11-15 12:34:09 -05:00
Jeremy Stretch
1d509a8ff8 Release v1.7.1 2016-11-15 12:13:42 -05:00
Jeremy Stretch
f2232a15d9 Merge pull request #689 from bemanuel/develop
Add Graphviz to Topology Maps
2016-11-14 12:10:13 -05:00
Jeremy Stretch
955abcef21 Fixes #691: Allow the assignment of power ports to PDUs 2016-11-14 11:29:03 -05:00
Jeremy Stretch
9eaf153673 Fixes #692: Form errors are not displayed on checkbox fields 2016-11-14 11:13:27 -05:00
Bruno Emanuel
8e71c0f2a8 Removed python-graphviz 2016-11-13 13:29:11 -03:00
Jeremy Stretch
18a516ee53 Closes #685: When assigning an IP to a device, automaitcally select the interface if only one exists 2016-11-11 15:29:40 -05:00
Jeremy Stretch
f5b2420b4b Merge pull request #686 from digitalocean/rir_stats
#667: Add utilization statistics to RIR list view
2016-11-11 15:13:41 -05:00
Jeremy Stretch
f569561997 Another PEP8 fix 2016-11-11 15:09:25 -05:00
Jeremy Stretch
99c2911a66 PEP8 fix 2016-11-11 15:04:14 -05:00
Jeremy Stretch
a0ee6b0d58 Closes #667: Added stats to RIR list view 2016-11-11 15:02:53 -05:00
Jeremy Stretch
d891c8c981 Incorporated stats into RIR list view 2016-11-11 12:45:24 -05:00
Jeremy Stretch
07e34fbe84 Fixes #678: Server error on device import specifying an invalid device type 2016-11-10 15:30:45 -05:00
Jeremy Stretch
7dfd32a5c4 Fixes #676: Server error when bulk editing device types 2016-11-10 15:15:55 -05:00
Jeremy Stretch
9c7f55d8d0 Fixes #674: Correct status assignment on IP address import 2016-11-10 15:01:05 -05:00
Bruno Emanuel
e496dc710f Add Graphviz to Topology Maps 2016-11-04 18:01:07 -03:00
Jeremy Stretch
13cdc44caf #667: Initial work on RIR statistics 2016-11-04 16:04:29 -04:00
Jeremy Stretch
1f3f9781d9 Post-release version bump 2016-11-03 15:13:15 -04:00
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
270 changed files with 8362 additions and 4174 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
*.sh text eol=lf

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

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

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

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

5
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

@@ -4,9 +4,9 @@ NetBox is an IP address management (IPAM) and data center infrastructure managem
NetBox runs as a web application atop the [Django](https://www.djangoproject.com/) Python framework with a [PostgreSQL](http://www.postgresql.org/) database. For a complete list of requirements, see `requirements.txt`. The code is available [on GitHub](https://github.com/digitalocean/netbox).
The complete documentation for Netbox can be found at [Read the Docs](http://netbox.readthedocs.io/en/latest/).
The complete documentation for NetBox can be found at [Read the Docs](http://netbox.readthedocs.io/en/stable/).
Questions? Comments? Please 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
@@ -25,6 +25,9 @@ Questions? Comments? Please join us on IRC in **#netbox** on **irc.freenode.net*
# Installation
Please see [the documentation](http://netbox.readthedocs.io/en/latest/) for instructions on installing NetBox.
Please see [the documentation](http://netbox.readthedocs.io/en/stable/) for instructions on installing NetBox. To upgrade NetBox, please download the [latest release](https://github.com/digitalocean/netbox/releases) and run `upgrade.sh`.
To upgrade NetBox, please download the [latest release](https://github.com/digitalocean/netbox/releases) and run `upgrade.sh`.
## Alternative Installations
* [Docker container](http://netbox.readthedocs.io/en/stable/installation/docker/)
* [Heroku deployment](https://heroku.com/deploy?template=https://github.com/BILDQUADRAT/netbox/tree/heroku) (via [@mraerino](https://github.com/BILDQUADRAT/netbox/tree/heroku))

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -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,33 @@
# Installation
NetBox requires following system dependencies:
**Debian/Ubuntu**
* python2.7
* python-dev
* python-pip
* libxml2-dev
* libxslt1-dev
* libffi-dev
* graphviz
* libpq-dev
* libssl-dev
Python 3:
```no-highlight
# apt-get install -y python3 python3-dev python3-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
Python 2:
```no-highlight
# apt-get install -y python2.7 python-dev python-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev
```
**CentOS/RHEL**
Python 3:
```no-highlight
# yum install -y epel-release
# yum install -y gcc python3 python3-devel python3-pip libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel
```
Python 2:
```no-highlight
# yum install -y epel-release
# yum install -y gcc python2 python-devel python-pip libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel
```
You may opt to install NetBox either from a numbered release or by cloning the master branch of its repository on GitHub.
@@ -22,7 +36,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 +48,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 +83,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 +108,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 +118,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 +141,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 +160,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 +192,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 +201,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 +212,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,21 +17,27 @@ 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
```
Copy the LDAP configuration if using LDAP:
```no-highlight
# cp /opt/netbox-X.Y.Z/netbox/netbox/ldap_config.py /opt/netbox/netbox/netbox/ldap_config.py
```
## Option B: Clone the Git Repository (latest master release)
This guide assumes that NetBox is installed at `/opt/netbox`. Pull down the most recent iteration of the master branch:
```
```no-highlight
# cd /opt/netbox
# git checkout master
# git pull origin master
@@ -42,7 +48,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 +62,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

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

View File

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

View File

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

View File

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

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

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

View File

@@ -5,21 +5,24 @@ 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, NumericInFilter
from .models import Provider, Circuit, CircuitType
class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter(
method='search',
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,9 +30,11 @@ class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet):
class Meta:
model = Provider
fields = ['q', 'name', 'account', 'asn']
fields = ['name', 'account', 'asn']
def search(self, queryset, value):
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(account__icontains=value) |
@@ -38,8 +43,9 @@ class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet):
class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter(
method='search',
label='Search',
)
provider_id = django_filters.ModelMultipleChoiceFilter(
@@ -48,7 +54,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 +65,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 +95,15 @@ 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):
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(cid__icontains=value) |
Q(xconnect_id__icontains=value) |
Q(pp_info__icontains=value) |
Q(terminations__xconnect_id__icontains=value) |
Q(terminations__pp_info__icontains=value) |
Q(description__icontains=value) |
Q(comments__icontains=value)
)
).distinct()

View File

@@ -1,15 +1,15 @@
from django import forms
from django.db.models import Count
from dcim.models import Site, Device, Interface, Rack, IFACE_FF_VIRTUAL
from dcim.models import Site, Device, Interface, Rack, VIRTUAL_IFACE_TYPES
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
from tenancy.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,24 @@ 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}))
q = forms.CharField(required=False, label='Search')
site = FilterChoiceField(queryset=Site.objects.all(), to_field_name='slug')
asn = forms.IntegerField(required=False, label='ASN')
#
# Circuit types
#
class CircuitTypeForm(forms.ModelForm, BootstrapMixin):
class CircuitTypeForm(BootstrapMixin, forms.ModelForm):
slug = SlugField()
class Meta:
@@ -85,41 +84,137 @@ class CircuitTypeForm(forms.ModelForm, BootstrapMixin):
#
class CircuitForm(BootstrapMixin, CustomFieldForm):
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'}))
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'
]
fields = ['cid', 'type', 'provider', 'tenant', 'install_date', 'commit_rate', 'description', 'comments']
help_texts = {
'cid': "Unique circuit ID",
'install_date': "Format: YYYY-MM-DD",
'commit_rate': "Committed rate",
}
class CircuitFromCSVForm(forms.ModelForm):
provider = forms.ModelChoiceField(Provider.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Provider not found.'})
type = forms.ModelChoiceField(CircuitType.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Invalid circuit type.'})
tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
error_messages={'invalid_choice': 'Tenant not found.'})
class Meta:
model = Circuit
fields = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description']
class CircuitImportForm(BootstrapMixin, BulkImportForm):
csv = CSVDataField(csv_form=CircuitFromCSVForm)
class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Circuit.objects.all(), widget=forms.MultipleHiddenInput)
type = forms.ModelChoiceField(queryset=CircuitType.objects.all(), required=False)
provider = forms.ModelChoiceField(queryset=Provider.objects.all(), required=False)
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
commit_rate = forms.IntegerField(required=False, label='Commit rate (Kbps)')
description = forms.CharField(max_length=100, required=False)
comments = CommentField(widget=SmallTextarea)
class Meta:
nullable_fields = ['tenant', 'commit_rate', 'description', 'comments']
class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Circuit
q = forms.CharField(required=False, label='Search')
type = FilterChoiceField(
queryset=CircuitType.objects.annotate(filter_count=Count('circuits')),
to_field_name='slug'
)
provider = FilterChoiceField(
queryset=Provider.objects.annotate(filter_count=Count('circuits')),
to_field_name='slug'
)
tenant = FilterChoiceField(
queryset=Tenant.objects.annotate(filter_count=Count('circuits')),
to_field_name='slug',
null_option=(0, 'None')
)
site = FilterChoiceField(
queryset=Site.objects.annotate(filter_count=Count('circuit_terminations')),
to_field_name='slug'
)
#
# 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', 'nullable': 'true'}
)
)
device = forms.ModelChoiceField(
queryset=Device.objects.all(),
required=False,
label='Device',
widget=APISelect(
api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}',
display_field='display_name',
attrs={'filter-for': 'interface'}
)
)
livesearch = forms.CharField(
required=False,
label='Device',
widget=Livesearch(
query_key='q',
query_url='dcim-api:device_list',
field_to_update='device'
)
)
interface = forms.ModelChoiceField(
queryset=Interface.objects.all(),
required=False,
label='Interface',
widget=APISelect(
api_url='/api/dcim/devices/{{device}}/interfaces/?type=physical',
disabled_indicator='is_connected'
)
)
class Meta:
model = CircuitTermination
fields = ['term_side', 'site', 'rack', 'device', 'livesearch', 'interface', 'port_speed', 'upstream_speed',
'xconnect_id', 'pp_info']
help_texts = {
'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
@@ -142,12 +237,18 @@ 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')
interfaces = Interface.objects.filter(device=self.data['device']).exclude(
form_factor__in=VIRTUAL_IFACE_TYPES
).select_related(
'circuit_termination', 'connected_as_a', 'connected_as_b'
)
self.fields['interface'].widget.attrs['initial'] = self.data.get('interface')
elif self.initial.get('device'):
interfaces = Interface.objects.filter(device=self.initial['device'])\
.exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit', 'connected_as_a', 'connected_as_b')
interfaces = Interface.objects.filter(device=self.initial['device']).exclude(
form_factor__in=VIRTUAL_IFACE_TYPES
).select_related(
'circuit_termination', 'connected_as_a', 'connected_as_b'
)
self.fields['interface'].widget.attrs['initial'] = self.initial.get('interface')
else:
interfaces = []
@@ -157,64 +258,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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,14 +1,19 @@
from django.contrib import messages
from django.contrib.auth.decorators import permission_required
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.urlresolvers import reverse
from django.db import transaction
from django.db.models import Count
from django.shortcuts import get_object_or_404, render
from django.shortcuts import get_object_or_404, redirect, render
from extras.models import Graph, GRAPH_TYPE_PROVIDER
from utilities.forms import ConfirmationForm
from utilities.views import (
BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
)
from . import filters, forms, tables
from .models import Circuit, CircuitType, Provider
from .models import Circuit, CircuitTermination, CircuitType, Provider, TERM_SIDE_A, TERM_SIDE_Z
#
@@ -20,14 +25,14 @@ class ProviderListView(ObjectListView):
filter = filters.ProviderFilter
filter_form = forms.ProviderFilterForm
table = tables.ProviderTable
edit_permissions = ['circuits.change_provider', 'circuits.delete_provider']
template_name = 'circuits/provider_list.html'
def provider(request, slug):
provider = get_object_or_404(Provider, slug=slug)
circuits = Circuit.objects.filter(provider=provider).select_related('site', 'interface__device')
circuits = Circuit.objects.filter(provider=provider).select_related('type', 'tenant')\
.prefetch_related('terminations__site')
show_graphs = Graph.objects.filter(type=GRAPH_TYPE_PROVIDER).exists()
return render(request, 'circuits/provider.html', {
@@ -42,13 +47,13 @@ class ProviderEditView(PermissionRequiredMixin, ObjectEditView):
model = Provider
form_class = forms.ProviderForm
template_name = 'circuits/provider_edit.html'
cancel_url = 'circuits:provider_list'
default_return_url = 'circuits:provider_list'
class ProviderDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'circuits.delete_provider'
model = Provider
redirect_url = 'circuits:provider_list'
default_return_url = 'circuits:provider_list'
class ProviderBulkImportView(PermissionRequiredMixin, BulkImportView):
@@ -56,21 +61,23 @@ class ProviderBulkImportView(PermissionRequiredMixin, BulkImportView):
form = forms.ProviderImportForm
table = tables.ProviderTable
template_name = 'circuits/provider_import.html'
obj_list_url = 'circuits:provider_list'
default_return_url = 'circuits:provider_list'
class ProviderBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'circuits.change_provider'
cls = Provider
filter = filters.ProviderFilter
form = forms.ProviderBulkEditForm
template_name = 'circuits/provider_bulk_edit.html'
default_redirect_url = 'circuits:provider_list'
default_return_url = 'circuits:provider_list'
class ProviderBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'circuits.delete_provider'
cls = Provider
default_redirect_url = 'circuits:provider_list'
filter = filters.ProviderFilter
default_return_url = 'circuits:provider_list'
#
@@ -80,7 +87,6 @@ class ProviderBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class CircuitTypeListView(ObjectListView):
queryset = CircuitType.objects.annotate(circuit_count=Count('circuits'))
table = tables.CircuitTypeTable
edit_permissions = ['circuits.change_circuittype', 'circuits.delete_circuittype']
template_name = 'circuits/circuittype_list.html'
@@ -88,14 +94,15 @@ class CircuitTypeEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'circuits.change_circuittype'
model = CircuitType
form_class = forms.CircuitTypeForm
success_url = 'circuits:circuittype_list'
cancel_url = 'circuits:circuittype_list'
def get_return_url(self, obj):
return reverse('circuits:circuittype_list')
class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'circuits.delete_circuittype'
cls = CircuitType
default_redirect_url = 'circuits:circuittype_list'
default_return_url = 'circuits:circuittype_list'
#
@@ -103,20 +110,31 @@ 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
edit_permissions = ['circuits.change_circuit', 'circuits.delete_circuit']
template_name = 'circuits/circuit_list.html'
def circuit(request, pk):
circuit = get_object_or_404(Circuit, pk=pk)
circuit = get_object_or_404(Circuit.objects.select_related('provider', 'type', 'tenant__group'), pk=pk)
termination_a = CircuitTermination.objects.select_related(
'site__region', 'interface__device'
).filter(
circuit=circuit, term_side=TERM_SIDE_A
).first()
termination_z = CircuitTermination.objects.select_related(
'site__region', 'interface__device'
).filter(
circuit=circuit, term_side=TERM_SIDE_Z
).first()
return render(request, 'circuits/circuit.html', {
'circuit': circuit,
'termination_a': termination_a,
'termination_z': termination_z,
})
@@ -124,15 +142,15 @@ class CircuitEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'circuits.change_circuit'
model = Circuit
form_class = forms.CircuitForm
fields_initial = ['site']
fields_initial = ['provider']
template_name = 'circuits/circuit_edit.html'
cancel_url = 'circuits:circuit_list'
default_return_url = 'circuits:circuit_list'
class CircuitDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'circuits.delete_circuit'
model = Circuit
redirect_url = 'circuits:circuit_list'
default_return_url = 'circuits:circuit_list'
class CircuitBulkImportView(PermissionRequiredMixin, BulkImportView):
@@ -140,18 +158,90 @@ class CircuitBulkImportView(PermissionRequiredMixin, BulkImportView):
form = forms.CircuitImportForm
table = tables.CircuitTable
template_name = 'circuits/circuit_import.html'
obj_list_url = 'circuits:circuit_list'
default_return_url = 'circuits:circuit_list'
class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'circuits.change_circuit'
cls = Circuit
filter = filters.CircuitFilter
form = forms.CircuitBulkEditForm
template_name = 'circuits/circuit_bulk_edit.html'
default_redirect_url = 'circuits:circuit_list'
default_return_url = 'circuits:circuit_list'
class CircuitBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'circuits.delete_circuit'
cls = Circuit
default_redirect_url = 'circuits:circuit_list'
filter = filters.CircuitFilter
default_return_url = 'circuits:circuit_list'
@permission_required('circuits.change_circuittermination')
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',
'return_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, request, url_args, url_kwargs):
if 'circuit' in url_kwargs:
obj.circuit = get_object_or_404(Circuit, pk=url_kwargs['circuit'])
return obj
def get_return_url(self, obj):
return obj.circuit.get_absolute_url()
class CircuitTerminationDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'circuits.delete_circuittermination'
model = CircuitTermination

View File

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

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

View File

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

View File

@@ -10,8 +10,9 @@ from django.http import Http404
from django.shortcuts import get_object_or_404
from dcim.models import (
ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, DeviceType, IFACE_FF_VIRTUAL, Interface,
InterfaceConnection, Manufacturer, Module, Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackRole, Site,
ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, DeviceType, Interface, InterfaceConnection,
Manufacturer, Module, Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackReservation, RackRole, Region, Site,
VIRTUAL_IFACE_TYPES,
)
from dcim import filters
from extras.api.views import CustomFieldModelAPIView
@@ -21,6 +22,26 @@ from .exceptions import MissingFilterException
from . import serializers
#
# Regions
#
class RegionListView(generics.ListAPIView):
"""
List all regions
"""
queryset = Region.objects.all()
serializer_class = serializers.RegionSerializer
class RegionDetailView(generics.RetrieveAPIView):
"""
Retrieve a single region
"""
queryset = Region.objects.all()
serializer_class = serializers.RegionSerializer
#
# Sites
#
@@ -118,7 +139,13 @@ class RackUnitListView(APIView):
rack = get_object_or_404(Rack, pk=pk)
face = request.GET.get('face', 0)
elevation = rack.get_rack_units(face)
exclude_pk = request.GET.get('exclude', None)
if exclude_pk is not None:
try:
exclude_pk = int(exclude_pk)
except ValueError:
exclude_pk = None
elevation = rack.get_rack_units(face, exclude_pk)
# Serialize Devices within the rack elevation
for u in elevation:
@@ -128,6 +155,27 @@ class RackUnitListView(APIView):
return Response(elevation)
#
# Rack reservations
#
class RackReservationListView(generics.ListAPIView):
"""
List all rack reservation
"""
queryset = RackReservation.objects.all()
serializer_class = serializers.RackReservationSerializer
filter_class = filters.RackReservationFilter
class RackReservationDetailView(generics.RetrieveAPIView):
"""
Retrieve a single rack reservation
"""
queryset = RackReservation.objects.all()
serializer_class = serializers.RackReservationSerializer
#
# Manufacturers
#
@@ -152,20 +200,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
@@ -217,10 +265,11 @@ class DeviceListView(CustomFieldModelAPIView, generics.ListAPIView):
"""
List devices (filterable)
"""
queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'tenant', 'platform',
'rack__site', 'parent_bay').prefetch_related('primary_ip4__nat_outside',
'primary_ip6__nat_outside',
'custom_field_values__field')
queryset = Device.objects.select_related(
'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'parent_bay'
).prefetch_related(
'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'custom_field_values__field'
)
serializer_class = serializers.DeviceSerializer
filter_class = filters.DeviceFilter
renderer_classes = api_settings.DEFAULT_RENDERER_CLASSES + [BINDZoneRenderer, FlatJSONRenderer]
@@ -230,8 +279,9 @@ class DeviceDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
"""
Retrieve a single device
"""
queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'tenant', 'platform',
'rack__site', 'parent_bay').prefetch_related('custom_field_values__field')
queryset = Device.objects.select_related(
'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'parent_bay'
).prefetch_related('custom_field_values__field')
serializer_class = serializers.DeviceSerializer
@@ -325,14 +375,15 @@ class InterfaceListView(generics.ListAPIView):
def get_queryset(self):
device = get_object_or_404(Device, pk=self.kwargs['pk'])
queryset = Interface.objects.filter(device=device).select_related('connected_as_a', 'connected_as_b')
queryset = Interface.objects.order_naturally(device.device_type.interface_ordering).filter(device=device)\
.select_related('connected_as_a', 'connected_as_b', 'circuit_termination')
# Filter by type (physical or virtual)
iface_type = self.request.query_params.get('type')
if iface_type == 'physical':
queryset = queryset.exclude(form_factor=IFACE_FF_VIRTUAL)
queryset = queryset.exclude(form_factor__in=VIRTUAL_IFACE_TYPES)
elif iface_type == 'virtual':
queryset = queryset.filter(form_factor=IFACE_FF_VIRTUAL)
queryset = queryset.filter(form_factor__in=VIRTUAL_IFACE_TYPES)
elif iface_type is not None:
queryset = queryset.empty()
@@ -451,7 +502,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:
@@ -483,8 +534,8 @@ class RelatedConnectionsView(APIView):
response['power-ports'].append(data)
# Interface connections
interfaces = Interface.objects.filter(device=device).select_related('connected_as_a', 'connected_as_b',
'circuit')
interfaces = Interface.objects.order_naturally(device.device_type.interface_ordering).filter(device=device)\
.select_related('connected_as_a', 'connected_as_b', 'circuit_termination')
for iface in interfaces:
data = serializers.InterfaceDetailSerializer(instance=iface).data
del(data['device'])

View File

@@ -1,26 +1,41 @@
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, NumericInFilter
from .models import (
ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, Interface, InterfaceConnection, Manufacturer,
Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackRole, Site,
ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, IFACE_FF_LAG, Interface, InterfaceConnection,
Manufacturer, Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackReservation, RackRole, Region, Site,
VIRTUAL_IFACE_TYPES,
)
class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter(
method='search',
label='Search',
)
tenant_id = django_filters.ModelMultipleChoiceFilter(
region_id = NullableModelMultipleChoiceFilter(
name='region',
queryset=Region.objects.all(),
label='Region (ID)',
)
region = NullableModelMultipleChoiceFilter(
name='region',
queryset=Region.objects.all(),
to_field_name='slug',
label='Region (slug)',
)
tenant_id = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
label='Tenant (ID)',
)
tenant = django_filters.ModelMultipleChoiceFilter(
tenant = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
to_field_name='slug',
@@ -31,9 +46,16 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
model = Site
fields = ['q', 'name', 'facility', 'asn']
def search(self, queryset, value):
qs_filter = Q(name__icontains=value) | Q(facility__icontains=value) | Q(physical_address__icontains=value) | \
Q(shipping_address__icontains=value) | Q(comments__icontains=value)
def search(self, queryset, name, value):
if not value.strip():
return queryset
qs_filter = (
Q(name__icontains=value) |
Q(facility__icontains=value) |
Q(physical_address__icontains=value) |
Q(shipping_address__icontains=value) |
Q(comments__icontains=value)
)
try:
qs_filter |= Q(asn=int(value.strip()))
except ValueError:
@@ -48,7 +70,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,12 +78,13 @@ class RackGroupFilter(django_filters.FilterSet):
class Meta:
model = RackGroup
fields = ['site_id', 'site']
fields = ['name']
class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter(
method='search',
label='Search',
)
site_id = django_filters.ModelMultipleChoiceFilter(
@@ -70,39 +93,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,9 +134,11 @@ 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):
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(facility_id__icontains=value) |
@@ -121,28 +146,10 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
)
class DeviceTypeFilter(django_filters.FilterSet):
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
name='manufacturer',
queryset=Manufacturer.objects.all(),
label='Manufacturer (ID)',
)
manufacturer = django_filters.ModelMultipleChoiceFilter(
name='manufacturer',
queryset=Manufacturer.objects.all(),
to_field_name='slug',
label='Manufacturer (slug)',
)
class Meta:
model = DeviceType
fields = ['manufacturer_id', 'manufacturer', 'model', 'part_number', 'u_height', 'is_console_server', 'is_pdu',
'is_network_device']
class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
class RackReservationFilter(django_filters.FilterSet):
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter(
method='search',
label='Search',
)
site_id = django_filters.ModelMultipleChoiceFilter(
@@ -151,7 +158,95 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
label='Site (ID)',
)
site = django_filters.ModelMultipleChoiceFilter(
name='rack__site',
name='rack__site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site (slug)',
)
group_id = NullableModelMultipleChoiceFilter(
name='rack__group',
queryset=RackGroup.objects.all(),
label='Group (ID)',
)
group = NullableModelMultipleChoiceFilter(
name='rack__group',
queryset=RackGroup.objects.all(),
to_field_name='slug',
label='Group',
)
rack_id = django_filters.ModelMultipleChoiceFilter(
name='rack',
queryset=Rack.objects.all(),
label='Rack (ID)',
)
class Meta:
model = RackReservation
fields = ['rack', 'user']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(rack__name__icontains=value) |
Q(rack__facility_id__icontains=value) |
Q(user__username__icontains=value) |
Q(description__icontains=value)
)
class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet):
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter(
method='search',
label='Search',
)
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
name='manufacturer',
queryset=Manufacturer.objects.all(),
label='Manufacturer (ID)',
)
manufacturer = django_filters.ModelMultipleChoiceFilter(
name='manufacturer__slug',
queryset=Manufacturer.objects.all(),
to_field_name='slug',
label='Manufacturer (slug)',
)
class Meta:
model = DeviceType
fields = [
'model', 'part_number', 'u_height', 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role',
]
def search(self, queryset, name, value):
if not value.strip():
return queryset
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):
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter(
method='search',
label='Search',
)
mac_address = django_filters.CharFilter(
method='_mac_address',
label='MAC address',
)
site_id = django_filters.ModelMultipleChoiceFilter(
name='site',
queryset=Site.objects.all(),
label='Site (ID)',
)
site = django_filters.ModelMultipleChoiceFilter(
name='site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site name (slug)',
@@ -161,7 +256,7 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
queryset=RackGroup.objects.all(),
label='Rack group (ID)',
)
rack_id = django_filters.ModelMultipleChoiceFilter(
rack_id = NullableModelMultipleChoiceFilter(
name='rack',
queryset=Rack.objects.all(),
label='Rack (ID)',
@@ -172,17 +267,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 +294,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,11 +335,11 @@ 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):
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(serial__icontains=value.strip()) |
@@ -253,6 +348,15 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
Q(comments__icontains=value)
).distinct()
def _mac_address(self, queryset, name, 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 +373,7 @@ class ConsolePortFilter(django_filters.FilterSet):
class Meta:
model = ConsolePort
fields = ['device_id', 'device', 'name']
fields = ['name']
class ConsoleServerPortFilter(django_filters.FilterSet):
@@ -287,7 +391,7 @@ class ConsoleServerPortFilter(django_filters.FilterSet):
class Meta:
model = ConsoleServerPort
fields = ['device_id', 'device', 'name']
fields = ['name']
class PowerPortFilter(django_filters.FilterSet):
@@ -305,7 +409,7 @@ class PowerPortFilter(django_filters.FilterSet):
class Meta:
model = PowerPort
fields = ['device_id', 'device', 'name']
fields = ['name']
class PowerOutletFilter(django_filters.FilterSet):
@@ -323,7 +427,7 @@ class PowerOutletFilter(django_filters.FilterSet):
class Meta:
model = PowerOutlet
fields = ['device_id', 'device', 'name']
fields = ['name']
class InterfaceFilter(django_filters.FilterSet):
@@ -338,58 +442,109 @@ class InterfaceFilter(django_filters.FilterSet):
to_field_name='name',
label='Device (name)',
)
type = django_filters.CharFilter(
method='filter_type',
label='Interface type',
)
mac_address = django_filters.CharFilter(
method='_mac_address',
label='MAC address',
)
class Meta:
model = Interface
fields = ['device_id', 'device', 'name']
fields = ['name']
def filter_type(self, queryset, name, value):
value = value.strip().lower()
if value == 'physical':
return queryset.exclude(form_factor__in=VIRTUAL_IFACE_TYPES)
elif value == 'virtual':
return queryset.filter(form_factor__in=VIRTUAL_IFACE_TYPES)
elif value == 'lag':
return queryset.filter(form_factor=IFACE_FF_LAG)
return queryset
def _mac_address(self, queryset, name, value):
value = value.strip()
if not value:
return queryset
try:
return queryset.filter(mac_address=value)
except AddrFormatError:
return queryset.none()
class ConsoleConnectionFilter(django_filters.FilterSet):
site = django_filters.MethodFilter(
action='filter_site',
site = django_filters.CharFilter(
method='filter_site',
label='Site (slug)',
)
device = django_filters.CharFilter(
method='filter_device',
label='Device',
)
class Meta:
model = ConsoleServerPort
def filter_site(self, queryset, value):
value = value.strip()
if not value:
def filter_site(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(cs_port__device__rack__site__slug=value)
return queryset.filter(cs_port__device__site__slug=value)
def filter_device(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(device__name__icontains=value) |
Q(cs_port__device__name__icontains=value)
)
class PowerConnectionFilter(django_filters.FilterSet):
site = django_filters.MethodFilter(
action='filter_site',
site = django_filters.CharFilter(
method='filter_site',
label='Site (slug)',
)
device = django_filters.CharFilter(
method='filter_device',
label='Device',
)
class Meta:
model = PowerOutlet
def filter_site(self, queryset, value):
value = value.strip()
if not value:
def filter_site(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(power_outlet__device__rack__site__slug=value)
return queryset.filter(power_outlet__device__site__slug=value)
def filter_device(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(device__name__icontains=value) |
Q(power_outlet__device__name__icontains=value)
)
class InterfaceConnectionFilter(django_filters.FilterSet):
site = django_filters.MethodFilter(
action='filter_site',
site = django_filters.CharFilter(
method='filter_site',
label='Site (slug)',
)
device = django_filters.CharFilter(
method='filter_device',
label='Device',
)
class Meta:
model = InterfaceConnection
def filter_site(self, queryset, value):
value = value.strip()
if not value:
def filter_site(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(interface_a__device__rack__site__slug=value) |
Q(interface_b__device__rack__site__slug=value)
Q(interface_a__device__site__slug=value) |
Q(interface_b__device__site__slug=value)
)
def filter_device(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(interface_a__device__name__icontains=value) |
Q(interface_b__device__name__icontains=value)
)

View File

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

View File

@@ -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"
}
},
{

File diff suppressed because it is too large Load Diff

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -6,12 +6,30 @@ from utilities.tables import BaseTable, ToggleColumn
from .models import (
ConsolePort, ConsolePortTemplate, ConsoleServerPortTemplate, Device, DeviceBayTemplate, DeviceRole, DeviceType,
Interface, InterfaceTemplate, Manufacturer, Platform, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack,
RackGroup, Site,
RackGroup, RackReservation, Region, Site,
)
REGION_LINK = """
{% if record.get_children %}
<span style="padding-left: {{ record.get_ancestors|length }}0px "><i class="fa fa-caret-right"></i>
{% else %}
<span style="padding-left: {{ record.get_ancestors|length }}9px">
{% endif %}
<a href="{% url 'dcim:site_list' %}?region={{ record.slug }}">{{ record.name }}</a>
</span>
"""
SITE_REGION_LINK = """
{% if record.region %}
<a href="{% url 'dcim:site_list' %}?region={{ record.region.slug }}">{{ record.region }}</a>
{% else %}
&mdash;
{% endif %}
"""
COLOR_LABEL = """
<label class="label {{ record.color }}">{{ record }}</label>
<label class="label" style="background-color: #{{ record.color }}">{{ record }}</label>
"""
DEVICE_LINK = """
@@ -20,6 +38,12 @@ DEVICE_LINK = """
</a>
"""
REGION_ACTIONS = """
{% if perms.dcim.change_region %}
<a href="{% url 'dcim:region_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
"""
RACKGROUP_ACTIONS = """
{% if perms.dcim.change_rackgroup %}
<a href="{% url 'dcim:rackgroup_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
@@ -34,12 +58,18 @@ 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 %}
"""
RACKRESERVATION_ACTIONS = """
{% if perms.dcim.change_rackreservation %}
<a href="{% url 'dcim:rackreservation_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
"""
DEVICEROLE_ACTIONS = """
{% if perms.dcim.change_devicerole %}
<a href="{% url 'dcim:devicerole_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
@@ -59,7 +89,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 = """
@@ -70,12 +100,42 @@ STATUS_ICON = """
{% endif %}
"""
DEVICE_PRIMARY_IP = """
{{ record.primary_ip6.address.ip|default:"" }}
{% if record.primary_ip6 and record.primary_ip4 %}<br />{% endif %}
{{ record.primary_ip4.address.ip|default:"" }}
"""
SUBDEVICE_ROLE_TEMPLATE = """
{% if record.subdevice_role == True %}Parent{% elif record.subdevice_role == False %}Child{% else %}&mdash;{% endif %}
"""
UTILIZATION_GRAPH = """
{% load helpers %}
{% utilization_graph record.get_utilization %}
{% utilization_graph value %}
"""
#
# Regions
#
class RegionTable(BaseTable):
pk = ToggleColumn()
name = tables.TemplateColumn(template_code=REGION_LINK, orderable=False)
site_count = tables.Column(verbose_name='Sites')
slug = tables.Column(verbose_name='Slug')
actions = tables.TemplateColumn(
template_code=REGION_ACTIONS,
attrs={'td': {'class': 'text-right'}},
verbose_name=''
)
class Meta(BaseTable.Meta):
model = Region
fields = ('pk', 'name', 'site_count', 'slug', 'actions')
#
# Sites
#
@@ -84,6 +144,7 @@ class SiteTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn('dcim:site', args=[Accessor('slug')], verbose_name='Name')
facility = tables.Column(verbose_name='Facility')
region = tables.TemplateColumn(template_code=SITE_REGION_LINK, verbose_name='Region')
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
asn = tables.Column(verbose_name='ASN')
rack_count = tables.Column(accessor=Accessor('count_racks'), orderable=False, verbose_name='Racks')
@@ -94,8 +155,10 @@ class SiteTable(BaseTable):
class Meta(BaseTable.Meta):
model = Site
fields = ('pk', 'name', 'facility', 'tenant', 'asn', 'rack_count', 'device_count', 'prefix_count',
'vlan_count', 'circuit_count')
fields = (
'pk', 'name', 'facility', 'region', 'tenant', 'asn', 'rack_count', 'device_count', 'prefix_count',
'vlan_count', 'circuit_count',
)
#
@@ -148,13 +211,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):
@@ -170,6 +232,23 @@ class RackImportTable(BaseTable):
fields = ('site', 'group', 'name', 'facility_id', 'tenant', 'u_height')
#
# Rack reservations
#
class RackReservationTable(BaseTable):
pk = ToggleColumn()
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')])
unit_list = tables.Column(orderable=False, verbose_name='Units')
actions = tables.TemplateColumn(
template_code=RACKRESERVATION_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name=''
)
class Meta(BaseTable.Meta):
model = RackReservation
fields = ('pk', 'rack', 'unit_list', 'user', 'created', 'description', 'actions')
#
# Manufacturers
#
@@ -196,10 +275,19 @@ 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')
is_console_server = tables.BooleanColumn(verbose_name='CS')
is_pdu = tables.BooleanColumn(verbose_name='PDU')
is_network_device = tables.BooleanColumn(verbose_name='Net')
subdevice_role = tables.TemplateColumn(SUBDEVICE_ROLE_TEMPLATE, verbose_name='Subdevice Role')
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', 'is_console_server', 'is_pdu',
'is_network_device', 'subdevice_role', 'instance_count'
)
#
@@ -293,7 +381,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
@@ -309,12 +398,13 @@ class DeviceTable(BaseTable):
status = tables.TemplateColumn(template_code=STATUS_ICON, verbose_name='')
name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name')
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
site = tables.Column(accessor=Accessor('rack.site'), verbose_name='Site')
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack')
device_role = tables.TemplateColumn(DEVICE_ROLE, verbose_name='Role')
device_type = tables.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 }}")
template_code=DEVICE_PRIMARY_IP)
class Meta(BaseTable.Meta):
model = Device
@@ -324,7 +414,7 @@ class DeviceTable(BaseTable):
class DeviceImportTable(BaseTable):
name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name')
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
site = tables.Column(accessor=Accessor('rack.site'), verbose_name='Site')
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack')
position = tables.Column(verbose_name='Position')
device_role = tables.Column(verbose_name='Role')
@@ -357,7 +447,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):
@@ -15,11 +17,15 @@ class SiteTest(APITestCase):
'id',
'name',
'slug',
'region',
'tenant',
'facility',
'asn',
'physical_address',
'shipping_address',
'contact_name',
'contact_phone',
'contact_email',
'comments',
'custom_fields',
'count_prefixes',
@@ -47,6 +53,7 @@ class SiteTest(APITestCase):
'type',
'width',
'u_height',
'desc_units',
'comments',
'custom_fields',
]
@@ -57,9 +64,9 @@ 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)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
@@ -67,20 +74,20 @@ 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)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
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)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in json.loads(response.content):
for i in json.loads(response.content.decode('utf-8')):
self.assertEqual(
sorted(i.keys()),
sorted(self.rack_fields),
@@ -91,11 +98,11 @@ 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)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in json.loads(response.content):
for i in json.loads(response.content.decode('utf-8')):
self.assertEqual(
sorted(i.keys()),
sorted(self.graph_fields),
@@ -127,6 +134,7 @@ class RackTest(APITestCase):
'type',
'width',
'u_height',
'desc_units',
'comments',
'custom_fields',
]
@@ -143,15 +151,17 @@ class RackTest(APITestCase):
'type',
'width',
'u_height',
'desc_units',
'reservations',
'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)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
@@ -163,9 +173,9 @@ 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)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
@@ -192,9 +202,9 @@ 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)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
@@ -202,9 +212,9 @@ 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)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
@@ -224,9 +234,14 @@ class DeviceTypeTest(APITestCase):
'part_number',
'u_height',
'is_full_depth',
'interface_ordering',
'is_console_server',
'is_pdu',
'is_network_device',
'subdevice_role',
'comments',
'custom_fields',
'instance_count',
]
nested_fields = [
@@ -236,9 +251,9 @@ 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)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
@@ -246,10 +261,10 @@ 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)
# content = json.loads(response.content.decode('utf-8'))
# self.assertEqual(response.status_code, status.HTTP_200_OK)
# self.assertEqual(
# sorted(content.keys()),
@@ -270,9 +285,9 @@ 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)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
@@ -280,9 +295,9 @@ 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)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
@@ -298,9 +313,9 @@ 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)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
@@ -308,9 +323,9 @@ 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)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
@@ -332,6 +347,7 @@ class DeviceTest(APITestCase):
'platform',
'serial',
'asset_tag',
'site',
'rack',
'position',
'face',
@@ -346,9 +362,9 @@ 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)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
for device in content:
self.assertEqual(
@@ -373,7 +389,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',
@@ -403,6 +419,9 @@ class DeviceTest(APITestCase):
'primary_ip4_family',
'primary_ip4_id',
'primary_ip6',
'site_id',
'site_name',
'site_slug',
'rack_display_name',
'rack_facility_id',
'rack_id',
@@ -413,7 +432,7 @@ class DeviceTest(APITestCase):
]
response = self.client.get(endpoint)
content = json.loads(response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
device = content[0]
self.assertEqual(
@@ -421,9 +440,9 @@ 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)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
@@ -439,9 +458,9 @@ 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)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
for console_port in content:
self.assertEqual(
@@ -461,9 +480,9 @@ 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)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
for console_port in content:
self.assertEqual(
@@ -479,9 +498,9 @@ 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)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
@@ -500,9 +519,9 @@ 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)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
@@ -514,9 +533,9 @@ 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)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
@@ -535,9 +554,9 @@ 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)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
@@ -558,6 +577,7 @@ class InterfaceTest(APITestCase):
'device',
'name',
'form_factor',
'lag',
'mac_address',
'mgmt_only',
'description',
@@ -571,6 +591,7 @@ class InterfaceTest(APITestCase):
'device',
'name',
'form_factor',
'lag',
'mac_address',
'mgmt_only',
'description',
@@ -585,9 +606,9 @@ 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)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
@@ -599,9 +620,9 @@ 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)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
@@ -612,19 +633,20 @@ class InterfaceTest(APITestCase):
sorted(DeviceTest.nested_fields),
)
def test_get_graph_list(self, endpoint='/api/dcim/interfaces/1/graphs/'):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
sorted(i.keys()),
sorted(SiteTest.graph_fields),
)
def test_get_interface_connections(self, endpoint='/api/dcim/interface-connections/4/'):
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)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
sorted(i.keys()),
sorted(SiteTest.graph_fields),
)
def test_get_interface_connections(self, endpoint='/{}api/dcim/interface-connections/4/'
.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
@@ -643,11 +665,10 @@ 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)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),

View File

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

View File

@@ -1,16 +1,19 @@
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 = [
# Regions
url(r'^regions/$', views.RegionListView.as_view(), name='region_list'),
url(r'^regions/add/$', views.RegionEditView.as_view(), name='region_add'),
url(r'^regions/delete/$', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'),
url(r'^regions/(?P<pk>\d+)/edit/$', views.RegionEditView.as_view(), name='region_edit'),
# Sites
url(r'^sites/$', views.SiteListView.as_view(), name='site_list'),
url(r'^sites/add/$', views.SiteEditView.as_view(), name='site_add'),
@@ -32,6 +35,12 @@ urlpatterns = [
url(r'^rack-roles/delete/$', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'),
url(r'^rack-roles/(?P<pk>\d+)/edit/$', views.RackRoleEditView.as_view(), name='rackrole_edit'),
# Rack reservations
url(r'^rack-reservations/$', views.RackReservationListView.as_view(), name='rackreservation_list'),
url(r'^rack-reservations/delete/$', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'),
url(r'^rack-reservations/(?P<pk>\d+)/edit/$', views.RackReservationEditView.as_view(), name='rackreservation_edit'),
url(r'^rack-reservations/(?P<pk>\d+)/delete/$', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'),
# Racks
url(r'^racks/$', views.RackListView.as_view(), name='rack_list'),
url(r'^racks/add/$', views.RackEditView.as_view(), name='rack_add'),
@@ -41,6 +50,7 @@ urlpatterns = [
url(r'^racks/(?P<pk>\d+)/$', views.rack, name='rack'),
url(r'^racks/(?P<pk>\d+)/edit/$', views.RackEditView.as_view(), name='rack_edit'),
url(r'^racks/(?P<pk>\d+)/delete/$', views.RackDeleteView.as_view(), name='rack_delete'),
url(r'^racks/(?P<rack>\d+)/reservations/add/$', views.RackReservationEditView.as_view(), name='rack_add_reservation'),
# Manufacturers
url(r'^manufacturers/$', views.ManufacturerListView.as_view(), name='manufacturer_list'),
@@ -75,6 +85,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 +118,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 +183,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

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

View File

@@ -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
)
@@ -33,9 +34,9 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
(0, 'False'),
)
if cf.default.lower() in ['true', 'yes', '1']:
initial = True
initial = 1
elif cf.default.lower() in ['false', 'no', '0']:
initial = False
initial = 0
else:
initial = None
field = forms.NullBooleanField(required=cf.required, initial=initial,
@@ -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

@@ -49,7 +49,7 @@ class Command(BaseCommand):
self.stdout.write("Running inventory for these sites: {}".format(', '.join(site_names)))
else:
raise CommandError("One or more sites specified but none found.")
device_list = device_list.filter(rack__site__in=sites)
device_list = device_list.filter(site__in=sites)
# --name: Filter devices by name matching a regex
if options['name']:

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

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11 on 2017-04-04 19:45
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '0004_topologymap_change_comma_to_semicolon'),
]
operations = [
migrations.AlterField(
model_name='useraction',
name='action',
field=models.PositiveSmallIntegerField(choices=[(1, b'created'), (7, b'bulk created'), (2, b'imported'), (3, b'modified'), (4, b'bulk edited'), (5, b'deleted'), (6, b'bulk deleted')]),
),
]

View File

@@ -8,11 +8,12 @@ from django.core.validators import ValidationError
from django.db import models
from django.http import HttpResponse
from django.template import Template, Context
from django.utils.encoding import python_2_unicode_compatible
from django.utils.safestring import mark_safe
CUSTOMFIELD_MODELS = (
'site', 'rack', 'device', # DCIM
'site', 'rack', 'devicetype', 'device', # DCIM
'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', # IPAM
'provider', 'circuit', # Circuits
'tenant', # Tenants
@@ -55,19 +56,32 @@ ACTION_EDIT = 3
ACTION_BULK_EDIT = 4
ACTION_DELETE = 5
ACTION_BULK_DELETE = 6
ACTION_BULK_CREATE = 7
ACTION_CHOICES = (
(ACTION_CREATE, 'created'),
(ACTION_BULK_CREATE, 'bulk created'),
(ACTION_IMPORT, 'imported'),
(ACTION_EDIT, 'modified'),
(ACTION_BULK_EDIT, 'bulk edited'),
(ACTION_DELETE, 'deleted'),
(ACTION_BULK_DELETE, 'bulk deleted')
(ACTION_BULK_DELETE, 'bulk deleted'),
)
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)
@@ -82,6 +96,7 @@ class CustomFieldModel(object):
return OrderedDict([(field, None) for field in fields])
@python_2_unicode_compatible
class CustomField(models.Model):
obj_type = models.ManyToManyField(ContentType, related_name='custom_fields', verbose_name='Object(s)',
limit_choices_to={'model__in': CUSTOMFIELD_MODELS},
@@ -103,7 +118,7 @@ class CustomField(models.Model):
class Meta:
ordering = ['weight', 'name']
def __unicode__(self):
def __str__(self):
return self.label or self.name.replace('_', ' ').capitalize()
def serialize_value(self, value):
@@ -119,7 +134,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,11 +150,14 @@ 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
@python_2_unicode_compatible
class CustomFieldValue(models.Model):
field = models.ForeignKey('CustomField', related_name='values')
obj_type = models.ForeignKey(ContentType, related_name='+', on_delete=models.PROTECT)
@@ -151,8 +169,8 @@ class CustomFieldValue(models.Model):
ordering = ['obj_type', 'obj_id']
unique_together = ['field', 'obj_type', 'obj_id']
def __unicode__(self):
return '{} {}'.format(self.obj, self.field)
def __str__(self):
return u'{} {}'.format(self.obj, self.field)
@property
def value(self):
@@ -170,6 +188,7 @@ class CustomFieldValue(models.Model):
super(CustomFieldValue, self).save(*args, **kwargs)
@python_2_unicode_compatible
class CustomFieldChoice(models.Model):
field = models.ForeignKey('CustomField', related_name='choices', limit_choices_to={'type': CF_TYPE_SELECT},
on_delete=models.CASCADE)
@@ -180,14 +199,21 @@ class CustomFieldChoice(models.Model):
ordering = ['field', 'weight', 'value']
unique_together = ['field', 'value']
def __unicode__(self):
def __str__(self):
return self.value
def clean(self):
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()
@python_2_unicode_compatible
class Graph(models.Model):
type = models.PositiveSmallIntegerField(choices=GRAPH_TYPE_CHOICES)
weight = models.PositiveSmallIntegerField(default=1000)
@@ -198,7 +224,7 @@ class Graph(models.Model):
class Meta:
ordering = ['type', 'weight', 'name']
def __unicode__(self):
def __str__(self):
return self.name
def embed_url(self, obj):
@@ -212,9 +238,11 @@ class Graph(models.Model):
return template.render(Context({'obj': obj}))
@python_2_unicode_compatible
class ExportTemplate(models.Model):
content_type = models.ForeignKey(ContentType, limit_choices_to={'model__in': EXPORTTEMPLATE_MODELS})
name = models.CharField(max_length=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)
@@ -225,7 +253,7 @@ class ExportTemplate(models.Model):
['content_type', 'name']
]
def __unicode__(self):
def __str__(self):
return u'{}: {}'.format(self.content_type, self.name)
def to_response(self, context_dict, filename):
@@ -244,20 +272,22 @@ class ExportTemplate(models.Model):
return response
@python_2_unicode_compatible
class TopologyMap(models.Model):
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
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:
ordering = ['name']
def __unicode__(self):
def __str__(self):
return self.name
@property
@@ -300,6 +330,9 @@ class UserActionManager(models.Manager):
def log_import(self, user, content_type, message=''):
self.log_bulk_action(user, content_type, ACTION_IMPORT, message)
def log_bulk_create(self, user, content_type, message=''):
self.log_bulk_action(user, content_type, ACTION_BULK_CREATE, message)
def log_bulk_edit(self, user, content_type, message=''):
self.log_bulk_action(user, content_type, ACTION_BULK_EDIT, message)
@@ -307,6 +340,7 @@ class UserActionManager(models.Manager):
self.log_bulk_action(user, content_type, ACTION_BULK_DELETE, message)
@python_2_unicode_compatible
class UserAction(models.Model):
"""
A record of an action (add, edit, or delete) performed on an object by a User.
@@ -323,13 +357,13 @@ class UserAction(models.Model):
class Meta:
ordering = ['-time']
def __unicode__(self):
def __str__(self):
if self.message:
return u'{} {}'.format(self.user, self.message)
return u'{} {} {}'.format(self.user, self.get_action_display(), self.content_type)
def icon(self):
if self.action in [ACTION_CREATE, ACTION_IMPORT]:
if self.action in [ACTION_CREATE, ACTION_BULK_CREATE, ACTION_IMPORT]:
return mark_safe('<i class="glyphicon glyphicon-plus text-success"></i>')
elif self.action in [ACTION_EDIT, ACTION_BULK_EDIT]:
return mark_safe('<i class="glyphicon glyphicon-pencil text-warning"></i>')

View File

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

View File

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

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

View File

@@ -7,33 +7,32 @@ 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, NumericInFilter
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):
q = django_filters.MethodFilter(
action='search',
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter(
method='search',
label='Search',
)
name = django_filters.CharFilter(
name='name',
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',
label='Tenant (slug)',
)
def search(self, queryset, value):
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(rd__icontains=value) |
@@ -45,9 +44,18 @@ class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet):
fields = ['name', 'rd']
class RIRFilter(django_filters.FilterSet):
id__in = NumericInFilter(name='id', lookup_expr='in')
class Meta:
model = RIR
fields = ['is_private']
class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter(
method='search',
label='Search',
)
rir_id = django_filters.ModelMultipleChoiceFilter(
@@ -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,9 +72,11 @@ 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):
def search(self, queryset, name, value):
if not value.strip():
return queryset
qs_filter = Q(description__icontains=value)
try:
prefix = str(IPNetwork(value.strip()).cidr)
@@ -77,43 +87,53 @@ class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet):
class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter(
method='search',
label='Search',
)
parent = django_filters.MethodFilter(
action='search_by_parent',
parent = django_filters.CharFilter(
method='search_by_parent',
label='Parent prefix',
)
vrf = django_filters.MethodFilter(
action='_vrf',
mask_length = django_filters.NumberFilter(
method='filter_mask_length',
label='Mask length',
)
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',
label='Site (slug)',
)
vlan_id = django_filters.ModelMultipleChoiceFilter(
vlan_id = NullableModelMultipleChoiceFilter(
name='vlan',
queryset=VLAN.objects.all(),
label='VLAN (ID)',
@@ -122,12 +142,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,9 +156,11 @@ 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):
def search(self, queryset, name, value):
if not value.strip():
return queryset
qs_filter = Q(description__icontains=value)
try:
prefix = str(IPNetwork(value.strip()).cidr)
@@ -147,7 +169,7 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
pass
return queryset.filter(qs_filter)
def search_by_parent(self, queryset, value):
def search_by_parent(self, queryset, name, value):
value = value.strip()
if not value:
return queryset
@@ -157,61 +179,47 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
except AddrFormatError:
return queryset.none()
def _vrf(self, queryset, value):
if str(value) == '':
def filter_mask_length(self, queryset, name, value):
if not 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)
)
return queryset.filter(prefix__net_mask_length=value)
class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter(
method='search',
label='Search',
)
parent = django_filters.MethodFilter(
action='search_by_parent',
parent = django_filters.CharFilter(
method='search_by_parent',
label='Parent prefix',
)
vrf = django_filters.MethodFilter(
action='_vrf',
mask_length = django_filters.NumberFilter(
method='filter_mask_length',
label='Mask length',
)
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 +227,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,9 +240,11 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
class Meta:
model = IPAddress
fields = ['q', 'family', 'vrf_id', 'vrf', 'device_id', 'device', 'interface_id']
fields = ['family', 'status']
def search(self, queryset, value):
def search(self, queryset, name, value):
if not value.strip():
return queryset
qs_filter = Q(description__icontains=value)
try:
ipaddress = str(IPNetwork(value.strip()))
@@ -243,53 +253,29 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
pass
return queryset.filter(qs_filter)
def search_by_parent(self, queryset, value):
def search_by_parent(self, queryset, name, value):
value = value.strip()
if not value:
return queryset
try:
query = str(IPNetwork(value).cidr)
return queryset.filter(address__net_contained_or_equal=query)
query = str(IPNetwork(value.strip()).cidr)
return queryset.filter(address__net_host_contained=query)
except AddrFormatError:
return queryset.none()
def _vrf(self, queryset, value):
if str(value) == '':
def filter_mask_length(self, queryset, name, value):
if not 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)
)
return queryset.filter(address__net_mask_length=value)
class VLANGroupFilter(django_filters.FilterSet):
site_id = django_filters.ModelMultipleChoiceFilter(
site_id = NullableModelMultipleChoiceFilter(
name='site',
queryset=Site.objects.all(),
label='Site (ID)',
)
site = django_filters.ModelMultipleChoiceFilter(
site = NullableModelMultipleChoiceFilter(
name='site',
queryset=Site.objects.all(),
to_field_name='slug',
@@ -298,62 +284,54 @@ class VLANGroupFilter(django_filters.FilterSet):
class Meta:
model = VLANGroup
fields = ['site_id', 'site']
fields = ['name']
class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter(
method='search',
label='Search',
)
site_id = django_filters.ModelMultipleChoiceFilter(
site_id = NullableModelMultipleChoiceFilter(
name='site',
queryset=Site.objects.all(),
label='Site (ID)',
)
site = django_filters.ModelMultipleChoiceFilter(
site = NullableModelMultipleChoiceFilter(
name='site',
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',
label='Group',
)
name = django_filters.CharFilter(
name='name',
lookup_type='icontains',
label='Name',
)
vid = django_filters.NumberFilter(
name='vid',
label='VLAN number (1-4095)',
)
tenant_id = 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,12 +340,32 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
class Meta:
model = VLAN
fields = ['site_id', 'site', 'vid', 'name', 'status', 'role_id', 'role']
fields = ['name', 'vid', 'status']
def search(self, queryset, value):
def search(self, queryset, name, value):
if not value.strip():
return queryset
qs_filter = Q(name__icontains=value) | Q(description__icontains=value)
try:
qs_filter |= Q(vid=int(value))
qs_filter |= Q(vid=int(value.strip()))
except ValueError:
pass
return queryset.filter(qs_filter)
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,36 +1,31 @@
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'),
(6, 'IPv6'),
]
PREFIX_MASK_LENGTH_CHOICES = [
('', '---------'),
] + [(i, i) for i in range(1, 128)]
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
IPADDRESS_MASK_LENGTH_CHOICES = PREFIX_MASK_LENGTH_CHOICES + [(128, 128)]
#
@@ -59,37 +54,44 @@ 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}))
q = forms.CharField(required=False, label='Search')
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('vrfs')), to_field_name='slug',
null_option=(0, None))
#
# 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 +119,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 +129,26 @@ 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
q = forms.CharField(required=False, label='Search')
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:
@@ -158,22 +162,14 @@ class RoleForm(forms.ModelForm, BootstrapMixin):
class PrefixForm(BootstrapMixin, CustomFieldForm):
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site',
widget=forms.Select(attrs={'filter-for': 'vlan'}))
widget=forms.Select(attrs={'filter-for': 'vlan', 'nullable': 'true'}))
vlan = forms.ModelChoiceField(queryset=VLAN.objects.all(), required=False, label='VLAN',
widget=APISelect(api_url='/api/ipam/vlans/?site_id={{site}}',
display_field='display_name'))
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)
@@ -186,17 +182,7 @@ class PrefixForm(BootstrapMixin, CustomFieldForm):
elif self.initial.get('site'):
self.fields['vlan'].queryset = VLAN.objects.filter(site=self.initial['site'])
else:
self.fields['vlan'].choices = []
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
self.fields['vlan'].queryset = VLAN.objects.filter(site=None)
class PrefixFromCSVForm(forms.ModelForm):
@@ -214,7 +200,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):
@@ -240,47 +226,36 @@ class PrefixFromCSVForm(forms.ModelForm):
elif vlan_vid and site:
try:
self.instance.vlan = VLAN.objects.get(site=site, vid=vlan_vid)
except VLAN.DoesNotExist:
self.add_error('vlan_vid', "Invalid VLAN ID ({}) for site {}.".format(vlan_vid, site))
except VLAN.MultipleObjectsReturned:
self.add_error('vlan_vid', "Multiple VLANs found ({} - VID {})".format(site, vlan_vid))
elif vlan_vid:
self.add_error('vlan_vid', "Must specify site and/or VLAN group when assigning a VLAN.")
def save(self, *args, **kwargs):
m = super(PrefixFromCSVForm, self).save(commit=False)
# Assign Prefix status by name
m.status = dict(self.fields['status_name'].choices)[self.cleaned_data['status_name']]
if kwargs.get('commit'):
m.save()
return m
self.instance.status = dict(self.fields['status_name'].choices)[self.cleaned_data['status_name']]
return super(PrefixFromCSVForm, self).save(*args, **kwargs)
class PrefixImportForm(BulkImportForm, BootstrapMixin):
class PrefixImportForm(BootstrapMixin, BulkImportForm):
csv = CSVDataField(csv_form=PrefixFromCSVForm)
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 +265,36 @@ 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',
q = forms.CharField(required=False, label='Search')
parent = forms.CharField(required=False, label='Parent prefix', widget=forms.TextInput(attrs={
'placeholder': 'Prefix',
}))
family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family')
vrf = 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}))
family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address family')
mask_length = forms.ChoiceField(required=False, choices=PREFIX_MASK_LENGTH_CHOICES, label='Mask length')
vrf = FilterChoiceField(
queryset=VRF.objects.annotate(filter_count=Count('prefixes')),
to_field_name='rd',
label='VRF',
null_option=(0, 'Global')
)
tenant = FilterChoiceField(
queryset=Tenant.objects.annotate(filter_count=Count('prefixes')),
to_field_name='slug',
null_option=(0, 'None')
)
status = forms.MultipleChoiceField(choices=prefix_status_choices, required=False)
site = FilterChoiceField(
queryset=Site.objects.annotate(filter_count=Count('prefixes')),
to_field_name='slug',
null_option=(0, 'None')
)
role = FilterChoiceField(
queryset=Role.objects.annotate(filter_count=Count('prefixes')),
to_field_name='slug',
null_option=(0, 'None')
)
expand = forms.BooleanField(required=False, label='Expand prefix hierarchy')
@@ -323,20 +307,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):
@@ -349,12 +330,14 @@ class IPAddressForm(BootstrapMixin, CustomFieldForm):
nat_inside = self.instance.nat_inside
# If the IP is assigned to an interface, populate site/device fields accordingly
if self.instance.nat_inside.interface:
self.initial['nat_site'] = self.instance.nat_inside.interface.device.rack.site.pk
self.initial['nat_site'] = self.instance.nat_inside.interface.device.site.pk
self.initial['nat_device'] = self.instance.nat_inside.interface.device.pk
self.fields['nat_device'].queryset = Device.objects.filter(
rack__site=nat_inside.interface.device.rack.site)
site=nat_inside.interface.device.site
)
self.fields['nat_inside'].queryset = IPAddress.objects.filter(
interface__device=nat_inside.interface.device)
interface__device=nat_inside.interface.device
)
else:
self.fields['nat_inside'].queryset = IPAddress.objects.filter(pk=nat_inside.pk)
@@ -362,9 +345,9 @@ class IPAddressForm(BootstrapMixin, CustomFieldForm):
# Initialize nat_device choices if nat_site is set
if self.is_bound and self.data.get('nat_site'):
self.fields['nat_device'].queryset = Device.objects.filter(rack__site__pk=self.data['nat_site'])
self.fields['nat_device'].queryset = Device.objects.filter(site__pk=self.data['nat_site'])
elif self.initial.get('nat_site'):
self.fields['nat_device'].queryset = Device.objects.filter(rack__site=self.initial['nat_site'])
self.fields['nat_device'].queryset = Device.objects.filter(site=self.initial['nat_site'])
else:
self.fields['nat_device'].choices = []
@@ -379,11 +362,79 @@ class IPAddressForm(BootstrapMixin, CustomFieldForm):
self.fields['nat_inside'].choices = []
class IPAddressBulkAddForm(BootstrapMixin, forms.Form):
address = ExpandableIPAddressField()
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF', empty_label='Global')
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
status = forms.ChoiceField(choices=IPADDRESS_STATUS_CHOICES)
description = forms.CharField(max_length=100, required=False)
class IPAddressAssignForm(BootstrapMixin, forms.Form):
site = forms.ModelChoiceField(
queryset=Site.objects.all(),
label='Site',
required=False,
widget=forms.Select(
attrs={'filter-for': 'rack'}
)
)
rack = forms.ModelChoiceField(
queryset=Rack.objects.all(),
label='Rack',
required=False,
widget=APISelect(
api_url='/api/dcim/racks/?site_id={{site}}',
display_field='display_name',
attrs={'filter-for': 'device', 'nullable': 'true'}
)
)
device = forms.ModelChoiceField(
queryset=Device.objects.all(),
label='Device',
required=False,
widget=APISelect(
api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}',
display_field='display_name',
attrs={'filter-for': 'interface'}
)
)
livesearch = forms.CharField(
required=False,
label='Device',
widget=Livesearch(
query_key='q',
query_url='dcim-api:device_list',
field_to_update='device'
)
)
interface = forms.ModelChoiceField(
queryset=Interface.objects.all(),
label='Interface',
widget=APISelect(
api_url='/api/dcim/devices/{{device}}/interfaces/'
)
)
set_as_primary = forms.BooleanField(
label='Set as primary IP for device',
required=False
)
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 +442,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 +465,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,42 +481,58 @@ 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):
model = IPAddress
parent = forms.CharField(required=False, label='Search Within', widget=forms.TextInput(attrs={
q = forms.CharField(required=False, label='Search')
parent = forms.CharField(required=False, label='Parent Prefix', widget=forms.TextInput(attrs={
'placeholder': 'Prefix',
}))
family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family')
vrf = 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}))
family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address family')
mask_length = forms.ChoiceField(required=False, choices=IPADDRESS_MASK_LENGTH_CHOICES, label='Mask length')
vrf = FilterChoiceField(
queryset=VRF.objects.annotate(filter_count=Count('ip_addresses')),
to_field_name='rd',
label='VRF',
null_option=(0, 'Global')
)
tenant = FilterChoiceField(
queryset=Tenant.objects.annotate(filter_count=Count('ip_addresses')),
to_field_name='slug',
null_option=(0, 'None')
)
status = forms.MultipleChoiceField(choices=ipaddress_status_choices, required=False)
#
# VLAN groups
#
class VLANGroupForm(forms.ModelForm, BootstrapMixin):
class VLANGroupForm(BootstrapMixin, forms.ModelForm):
slug = SlugField()
class Meta:
@@ -470,14 +540,12 @@ 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',
null_option=(0, 'Global')
)
#
@@ -493,7 +561,7 @@ class VLANForm(BootstrapMixin, CustomFieldForm):
model = VLAN
fields = ['site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description']
help_texts = {
'site': "The site at which this VLAN exists",
'site': "Leave blank if this VLAN spans multiple sites",
'group': "VLAN group (optional)",
'vid': "Configured VLAN ID",
'name': "Configured VLAN name",
@@ -501,7 +569,7 @@ class VLANForm(BootstrapMixin, CustomFieldForm):
'role': "The primary function of this VLAN",
}
widgets = {
'site': forms.Select(attrs={'filter-for': 'group'}),
'site': forms.Select(attrs={'filter-for': 'group', 'nullable': 'true'}),
}
def __init__(self, *args, **kwargs):
@@ -514,34 +582,58 @@ class VLANForm(BootstrapMixin, CustomFieldForm):
elif self.initial.get('site'):
self.fields['group'].queryset = VLANGroup.objects.filter(site=self.initial['site'])
else:
self.fields['group'].choices = []
self.fields['group'].queryset = VLANGroup.objects.filter(site=None)
class VLANFromCSVForm(forms.ModelForm):
site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Device 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,
error_messages={'invalid_choice': 'Tenant not found.'})
site = forms.ModelChoiceField(
queryset=Site.objects.all(), required=False, to_field_name='name',
error_messages={'invalid_choice': 'Site not found.'}
)
group_name = forms.CharField(required=False)
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 VLAN_STATUS_CHOICES])
role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False, to_field_name='name',
error_messages={'invalid_choice': 'Invalid role.'})
role = forms.ModelChoiceField(
queryset=Role.objects.all(), required=False, to_field_name='name',
error_messages={'invalid_choice': 'Invalid role.'}
)
class Meta:
model = VLAN
fields = ['site', 'group', 'vid', 'name', 'tenant', 'status_name', 'role', 'description']
fields = ['site', 'group_name', 'vid', 'name', 'tenant', 'status_name', 'role', 'description']
def clean(self):
super(VLANFromCSVForm, self).clean()
# Validate VLANGroup
group_name = self.cleaned_data.get('group_name')
if group_name:
try:
vlan_group = VLANGroup.objects.get(site=self.cleaned_data.get('site'), name=group_name)
except VLANGroup.DoesNotExist:
self.add_error('group_name', "Invalid VLAN group {}.".format(group_name))
def save(self, *args, **kwargs):
m = super(VLANFromCSVForm, self).save(commit=False)
vlan = super(VLANFromCSVForm, self).save(commit=False)
# Assign VLANGroup by site and name
if self.cleaned_data['group_name']:
vlan.group = VLANGroup.objects.get(site=self.cleaned_data['site'], name=self.cleaned_data['group_name'])
# Assign VLAN status by name
m.status = dict(self.fields['status_name'].choices)[self.cleaned_data['status_name']]
vlan.status = dict(self.fields['status_name'].choices)[self.cleaned_data['status_name']]
if kwargs.get('commit'):
m.save()
return m
vlan.save()
return vlan
class VLANImportForm(BulkImportForm, BootstrapMixin):
class VLANImportForm(BootstrapMixin, BulkImportForm):
csv = CSVDataField(csv_form=VLANFromCSVForm)
@@ -549,25 +641,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 +657,49 @@ 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}))
q = forms.CharField(required=False, label='Search')
site = FilterChoiceField(
queryset=Site.objects.annotate(filter_count=Count('vlans')),
to_field_name='slug',
null_option=(0, 'Global')
)
group_id = FilterChoiceField(
queryset=VLANGroup.objects.annotate(filter_count=Count('vlans')),
label='VLAN group',
null_option=(0, 'None')
)
tenant = FilterChoiceField(
queryset=Tenant.objects.annotate(filter_count=Count('vlans')),
to_field_name='slug',
null_option=(0, 'None')
)
status = forms.MultipleChoiceField(choices=vlan_status_choices, required=False)
role = FilterChoiceField(
queryset=Role.objects.annotate(filter_count=Count('vlans')),
to_field_name='slug',
null_option=(0, 'None')
)
#
# 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

@@ -1,4 +1,4 @@
from django.db.models import Lookup
from django.db.models import Lookup, Transform, IntegerField
from django.db.models.lookups import BuiltinLookup
@@ -87,3 +87,26 @@ class NetHost(Lookup):
rhs_params[0] = rhs_params[0].split('/')[0]
params = lhs_params + rhs_params
return 'HOST(%s) = %s' % (lhs, rhs), params
class NetHostContained(Lookup):
"""
Check for the host portion of an IP address without regard to its mask. This allows us to find e.g. 192.0.2.1/24
when specifying a parent prefix of 192.0.2.0/26.
"""
lookup_name = 'net_host_contained'
def as_sql(self, qn, connection):
lhs, lhs_params = self.process_lhs(qn, connection)
rhs, rhs_params = self.process_rhs(qn, connection)
params = lhs_params + rhs_params
return 'CAST(HOST(%s) AS INET) << %s' % (lhs, rhs), params
class NetMaskLength(Transform):
lookup_name = 'net_mask_length'
function = 'MASKLEN'
@property
def output_field(self):
return IntegerField()

View File

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

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

View File

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

View File

@@ -7,11 +7,14 @@ from django.core.urlresolvers import reverse
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models.expressions import RawSQL
from django.utils.encoding import python_2_unicode_compatible
from dcim.models import Interface
from extras.models import CustomFieldModel, CustomFieldValue
from tenancy.models import Tenant
from utilities.models import CreatedUpdatedModel
from utilities.sql import NullsFirstQuerySet
from utilities.utils import csv_format
from .fields import IPNetworkField, IPAddressField
@@ -21,17 +24,35 @@ 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_DEPRECATED = 3
IPADDRESS_STATUS_DHCP = 5
IPADDRESS_STATUS_CHOICES = (
(IPADDRESS_STATUS_ACTIVE, 'Active'),
(IPADDRESS_STATUS_RESERVED, 'Reserved'),
(IPADDRESS_STATUS_DEPRECATED, 'Deprecated'),
(IPADDRESS_STATUS_DHCP, 'DHCP')
)
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 +60,20 @@ 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'),
)
@python_2_unicode_compatible
class VRF(CreatedUpdatedModel, CustomFieldModel):
"""
A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing
@@ -61,22 +93,23 @@ class VRF(CreatedUpdatedModel, CustomFieldModel):
verbose_name = 'VRF'
verbose_name_plural = 'VRFs'
def __unicode__(self):
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('ipam:vrf', args=[self.pk])
def to_csv(self):
return ','.join([
return csv_format([
self.name,
self.rd,
self.tenant.name if self.tenant else '',
'True' if self.enforce_unique else '',
self.tenant.name if self.tenant else None,
self.enforce_unique,
self.description,
])
@python_2_unicode_compatible
class RIR(models.Model):
"""
A Regional Internet Registry (RIR) is responsible for the allocation of a large portion of the global IP address
@@ -84,19 +117,22 @@ 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']
verbose_name = 'RIR'
verbose_name_plural = 'RIRs'
def __unicode__(self):
def __str__(self):
return self.name
def get_absolute_url(self):
return "{}?rir={}".format(reverse('ipam:aggregate_list'), self.slug)
@python_2_unicode_compatible
class Aggregate(CreatedUpdatedModel, CustomFieldModel):
"""
An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize
@@ -112,7 +148,7 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel):
class Meta:
ordering = ['family', 'prefix']
def __unicode__(self):
def __str__(self):
return str(self.prefix)
def get_absolute_url(self):
@@ -130,16 +166,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:
@@ -148,10 +190,10 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel):
super(Aggregate, self).save(*args, **kwargs)
def to_csv(self):
return ','.join([
str(self.prefix),
return csv_format([
self.prefix,
self.rir.name,
self.date_added.isoformat() if self.date_added else '',
self.date_added.isoformat() if self.date_added else None,
self.description,
])
@@ -168,6 +210,7 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel):
return int(children_size / self.prefix.size * 100)
@python_2_unicode_compatible
class Role(models.Model):
"""
A Role represents the functional role of a Prefix or VLAN; for example, "Customer," "Infrastructure," or
@@ -180,7 +223,7 @@ class Role(models.Model):
class Meta:
ordering = ['weight', 'name']
def __unicode__(self):
def __str__(self):
return self.name
@property
@@ -192,7 +235,7 @@ class Role(models.Model):
return self.vlans.count()
class PrefixQuerySet(models.QuerySet):
class PrefixQuerySet(NullsFirstQuerySet):
def annotate_depth(self, limit=None):
"""
@@ -224,9 +267,10 @@ class PrefixQuerySet(models.QuerySet):
p.depth = len(stack) - 1
if limit is None:
return queryset
return filter(lambda p: p.depth <= limit, queryset)
return list(filter(lambda p: p.depth <= limit, queryset))
@python_2_unicode_compatible
class Prefix(CreatedUpdatedModel, CustomFieldModel):
"""
A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and
@@ -234,39 +278,61 @@ 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):
def __str__(self):
return str(self.prefix)
def get_absolute_url(self):
return reverse('ipam:prefix', args=[self.pk])
def get_duplicates(self):
return Prefix.objects.filter(vrf=self.vrf, prefix=str(self.prefix)).exclude(pk=self.pk)
def clean(self):
# Disallow host masks
if self.prefix:
# Disallow host masks
if self.prefix.version == 4 and self.prefix.prefixlen == 32:
raise ValidationError("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."
})
# Enforce unique IP space (if applicable)
if (self.vrf is None and settings.ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique):
duplicate_prefixes = self.get_duplicates()
if duplicate_prefixes:
raise ValidationError({
'prefix': "Duplicate prefix found in {}: {}".format(
"VRF {}".format(self.vrf) if self.vrf else "global table",
duplicate_prefixes.first(),
)
})
def save(self, *args, **kwargs):
if self.prefix:
@@ -277,13 +343,16 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
super(Prefix, self).save(*args, **kwargs)
def to_csv(self):
return ','.join([
str(self.prefix),
self.vrf.rd if self.vrf else '',
self.tenant.name if self.tenant else '',
self.site.name if self.site else '',
return csv_format([
self.prefix,
self.vrf.rd if self.vrf else None,
self.tenant.name if self.tenant else None,
self.site.name if self.site else None,
self.vlan.group.name if self.vlan and self.vlan.group else None,
self.vlan.vid if self.vlan else None,
self.get_status_display(),
self.role.name if self.role else '',
self.role.name if self.role else None,
self.is_pool,
self.description,
])
@@ -316,6 +385,7 @@ class IPAddressManager(models.Manager):
return qs.annotate(host=RawSQL('INET(HOST(ipam_ipaddress.address))', [])).order_by('family', 'host')
@python_2_unicode_compatible
class IPAddress(CreatedUpdatedModel, CustomFieldModel):
"""
An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is
@@ -328,14 +398,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')
@@ -346,26 +418,29 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
verbose_name = 'IP address'
verbose_name_plural = 'IP addresses'
def __unicode__(self):
def __str__(self):
return str(self.address)
def get_absolute_url(self):
return reverse('ipam:ipaddress', args=[self.pk])
def get_duplicates(self):
return IPAddress.objects.filter(vrf=self.vrf, address__net_host=str(self.address.ip)).exclude(pk=self.pk)
def clean(self):
# Enforce unique IP space if applicable
if self.vrf and self.vrf.enforce_unique:
duplicate_ips = IPAddress.objects.filter(vrf=self.vrf, address__net_host=str(self.address.ip))\
.exclude(pk=self.pk)
if duplicate_ips:
raise ValidationError("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()))
if self.address:
# Enforce unique IP space (if applicable)
if (self.vrf is None and settings.ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique):
duplicate_ips = self.get_duplicates()
if duplicate_ips:
raise ValidationError({
'address': "Duplicate IP address found in {}: {}".format(
"VRF {}".format(self.vrf) if self.vrf else "global table",
duplicate_ips.first(),
)
})
def save(self, *args, **kwargs):
if self.address:
@@ -382,13 +457,14 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
elif self.family == 6 and getattr(self, 'primary_ip6_for', False):
is_primary = True
return ','.join([
str(self.address),
self.vrf.rd if self.vrf else '',
self.tenant.name if self.tenant else '',
self.device.identifier if self.device else '',
self.interface.name if self.interface else '',
'True' if is_primary else '',
return csv_format([
self.address,
self.vrf.rd if self.vrf else None,
self.tenant.name if self.tenant else None,
self.get_status_display(),
self.device.identifier if self.device else None,
self.interface.name if self.interface else None,
is_primary,
self.description,
])
@@ -398,14 +474,18 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
return self.interface.device
return None
def get_status_class(self):
return STATUS_CHOICE_CLASSES[self.status]
@python_2_unicode_compatible
class VLANGroup(models.Model):
"""
A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique.
"""
name = models.CharField(max_length=50)
slug = models.SlugField()
site = models.ForeignKey('dcim.Site', related_name='vlan_groups')
site = models.ForeignKey('dcim.Site', related_name='vlan_groups', on_delete=models.PROTECT, blank=True, null=True)
class Meta:
ordering = ['site', 'name']
@@ -416,13 +496,16 @@ class VLANGroup(models.Model):
verbose_name = 'VLAN group'
verbose_name_plural = 'VLAN groups'
def __unicode__(self):
def __str__(self):
if self.site is None:
return self.name
return u'{} - {}'.format(self.site.name, self.name)
def get_absolute_url(self):
return "{}?group_id={}".format(reverse('ipam:vlan_list'), self.pk)
@python_2_unicode_compatible
class VLAN(CreatedUpdatedModel, CustomFieldModel):
"""
A VLAN is a distinct layer two forwarding domain identified by a 12-bit integer (1-4094). Each VLAN must be assigned
@@ -432,7 +515,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
Like Prefixes, each VLAN is assigned an operational status and optionally a user-defined Role. A VLAN can have zero
or more Prefixes assigned to it.
"""
site = models.ForeignKey('dcim.Site', related_name='vlans', on_delete=models.PROTECT)
site = models.ForeignKey('dcim.Site', related_name='vlans', on_delete=models.PROTECT, blank=True, null=True)
group = models.ForeignKey('VLANGroup', related_name='vlans', blank=True, null=True, on_delete=models.PROTECT)
vid = models.PositiveSmallIntegerField(verbose_name='ID', validators=[
MinValueValidator(1),
@@ -454,7 +537,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
verbose_name = 'VLAN'
verbose_name_plural = 'VLANs'
def __unicode__(self):
def __str__(self):
return self.display_name
def get_absolute_url(self):
@@ -464,17 +547,19 @@ 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([
self.site.name,
self.group.name if self.group else '',
str(self.vid),
return csv_format([
self.site.name if self.site else None,
self.group.name if self.group else None,
self.vid,
self.name,
self.tenant.name if self.tenant else '',
self.tenant.name if self.tenant else None,
self.get_status_display(),
self.role.name if self.role else '',
self.role.name if self.role else None,
self.description,
])
@@ -484,3 +569,26 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
def get_status_class(self):
return STATUS_CHOICE_CLASSES[self.status]
@python_2_unicode_compatible
class Service(CreatedUpdatedModel):
"""
A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device. A Service may optionally be tied
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 __str__(self):
return u'{} ({}/{})'.format(self.name, self.port, self.get_protocol_display())

View File

@@ -6,6 +6,25 @@ from utilities.tables import BaseTable, ToggleColumn
from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF
RIR_UTILIZATION = """
<div class="progress">
{% if record.stats.total %}
<div class="progress-bar" role="progressbar" style="width: {{ record.stats.percentages.active }}%;">
<span class="sr-only">{{ record.stats.percentages.active }}%</span>
</div>
<div class="progress-bar progress-bar-info" role="progressbar" style="width: {{ record.stats.percentages.reserved }}%;">
<span class="sr-only">{{ record.stats.percentages.reserved }}%</span>
</div>
<div class="progress-bar progress-bar-danger" role="progressbar" style="width: {{ record.stats.percentages.deprecated }}%;">
<span class="sr-only">{{ record.stats.percentages.deprecated }}%</span>
</div>
<div class="progress-bar progress-bar-success" role="progressbar" style="width: {{ record.stats.percentages.available }}%;">
<span class="sr-only">{{ record.stats.percentages.available }}%</span>
</div>
{% endif %}
</div>
"""
RIR_ACTIONS = """
{% if perms.ipam.change_rir %}
<a href="{% url 'ipam:rir_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
@@ -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>
@@ -93,7 +136,7 @@ class VRFTable(BaseTable):
name = tables.LinkColumn('ipam:vrf', args=[Accessor('pk')], verbose_name='Name')
rd = tables.Column(verbose_name='RD')
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
description = tables.Column(orderable=False, verbose_name='Description')
description = tables.Column(verbose_name='Description')
class Meta(BaseTable.Meta):
model = VRF
@@ -107,13 +150,25 @@ class VRFTable(BaseTable):
class RIRTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn(verbose_name='Name')
is_private = tables.BooleanColumn(verbose_name='Private')
aggregate_count = tables.Column(verbose_name='Aggregates')
slug = tables.Column(verbose_name='Slug')
stats_total = tables.Column(accessor='stats.total', verbose_name='Total',
footer=lambda table: sum(r.stats['total'] for r in table.data))
stats_active = tables.Column(accessor='stats.active', verbose_name='Active',
footer=lambda table: sum(r.stats['active'] for r in table.data))
stats_reserved = tables.Column(accessor='stats.reserved', verbose_name='Reserved',
footer=lambda table: sum(r.stats['reserved'] for r in table.data))
stats_deprecated = tables.Column(accessor='stats.deprecated', verbose_name='Deprecated',
footer=lambda table: sum(r.stats['deprecated'] for r in table.data))
stats_available = tables.Column(accessor='stats.available', verbose_name='Available',
footer=lambda table: sum(r.stats['available'] for r in table.data))
utilization = tables.TemplateColumn(template_code=RIR_UTILIZATION, verbose_name='Utilization')
actions = tables.TemplateColumn(template_code=RIR_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name='')
class Meta(BaseTable.Meta):
model = RIR
fields = ('pk', 'name', 'aggregate_count', 'slug', 'actions')
fields = ('pk', 'name', 'is_private', 'aggregate_count', 'stats_total', 'stats_active', 'stats_reserved',
'stats_deprecated', 'stats_available', 'utilization', 'actions')
#
@@ -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')
description = tables.Column(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')
description = tables.Column(orderable=False, verbose_name='Description')
vlan = tables.LinkColumn('ipam:vlan', args=[Accessor('vlan.pk')], verbose_name='VLAN')
role = tables.TemplateColumn(PREFIX_ROLE_LINK, verbose_name='Role')
description = tables.Column(verbose_name='Description')
class Meta(BaseTable.Meta):
model = Prefix
fields = ('pk', 'prefix', 'status', 'vrf', 'tenant', 'site', 'role', 'description')
fields = ('pk', 'prefix', 'status', 'vrf', 'tenant', 'site', 'vlan', 'role', 'description')
row_attrs = {
'class': lambda record: 'success' if not record.pk else '',
}
@@ -178,11 +234,12 @@ class PrefixBriefTable(BaseTable):
vrf = tables.LinkColumn('ipam:vrf', args=[Accessor('vrf.pk')], default='Global', verbose_name='VRF')
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
vlan = tables.LinkColumn('ipam:vlan', args=[Accessor('vlan.pk')], verbose_name='VLAN')
role = tables.Column(verbose_name='Role')
class Meta(BaseTable.Meta):
model = Prefix
fields = ('prefix', 'vrf', 'status', 'site', 'role')
fields = ('prefix', 'vrf', 'status', 'site', 'vlan', 'role')
orderable = False
@@ -193,16 +250,17 @@ 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,
verbose_name='Device')
interface = tables.Column(orderable=False, verbose_name='Interface')
description = tables.Column(orderable=False, verbose_name='Description')
description = tables.Column(verbose_name='Description')
class Meta(BaseTable.Meta):
model = IPAddress
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 +307,12 @@ class VLANTable(BaseTable):
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
name = tables.Column(verbose_name='Name')
prefixes = tables.TemplateColumn(VLAN_PREFIXES, orderable=False, verbose_name='Prefixes')
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
role = tables.Column(verbose_name='Role')
role = tables.TemplateColumn(VLAN_ROLE_LINK, verbose_name='Role')
description = tables.Column(verbose_name='Description')
class Meta(BaseTable.Meta):
model = VLAN
fields = ('pk', 'vid', 'site', 'group', 'name', 'tenant', 'status', 'role')
fields = ('pk', 'vid', 'site', 'group', 'name', 'prefixes', 'tenant', 'status', 'role', 'description')

View File

View File

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

View File

@@ -51,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,26 @@
import netaddr
from django_tables2 import RequestConfig
import netaddr
from django.conf import settings
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 +39,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:
@@ -91,15 +96,16 @@ class VRFListView(ObjectListView):
filter = filters.VRFFilter
filter_form = forms.VRFFilterForm
table = tables.VRFTable
edit_permissions = ['ipam.change_vrf', 'ipam.delete_vrf']
template_name = 'ipam/vrf_list.html'
def vrf(request, pk):
vrf = get_object_or_404(VRF.objects.all(), pk=pk)
prefixes = Prefix.objects.filter(vrf=vrf)
prefix_table = tables.PrefixBriefTable(prefixes)
prefix_table = tables.PrefixBriefTable(
list(Prefix.objects.filter(vrf=vrf).select_related('site', 'role'))
)
prefix_table.exclude = ('vrf',)
return render(request, 'ipam/vrf.html', {
'vrf': vrf,
@@ -112,13 +118,13 @@ class VRFEditView(PermissionRequiredMixin, ObjectEditView):
model = VRF
form_class = forms.VRFForm
template_name = 'ipam/vrf_edit.html'
cancel_url = 'ipam:vrf_list'
default_return_url = 'ipam:vrf_list'
class VRFDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_vrf'
model = VRF
redirect_url = 'ipam:vrf_list'
default_return_url = 'ipam:vrf_list'
class VRFBulkImportView(PermissionRequiredMixin, BulkImportView):
@@ -126,21 +132,23 @@ class VRFBulkImportView(PermissionRequiredMixin, BulkImportView):
form = forms.VRFImportForm
table = tables.VRFTable
template_name = 'ipam/vrf_import.html'
obj_list_url = 'ipam:vrf_list'
default_return_url = 'ipam:vrf_list'
class VRFBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'ipam.change_vrf'
cls = VRF
filter = filters.VRFFilter
form = forms.VRFBulkEditForm
template_name = 'ipam/vrf_bulk_edit.html'
default_redirect_url = 'ipam:vrf_list'
default_return_url = 'ipam:vrf_list'
class VRFBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_vrf'
cls = VRF
default_redirect_url = 'ipam:vrf_list'
filter = filters.VRFFilter
default_return_url = 'ipam:vrf_list'
#
@@ -149,23 +157,102 @@ class VRFBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class RIRListView(ObjectListView):
queryset = RIR.objects.annotate(aggregate_count=Count('aggregates'))
filter = filters.RIRFilter
filter_form = forms.RIRFilterForm
table = tables.RIRTable
edit_permissions = ['ipam.change_rir', 'ipam.delete_rir']
template_name = 'ipam/rir_list.html'
def alter_queryset(self, request):
if request.GET.get('family') == '6':
family = 6
denominator = 2 ** 64 # Count /64s for IPv6 rather than individual IPs
else:
family = 4
denominator = 1
rirs = []
for rir in self.queryset:
stats = {
'total': 0,
'active': 0,
'reserved': 0,
'deprecated': 0,
'available': 0,
}
aggregate_list = Aggregate.objects.filter(family=family, rir=rir)
for aggregate in aggregate_list:
queryset = Prefix.objects.filter(prefix__net_contained_or_equal=str(aggregate.prefix))
# Find all consumed space for each prefix status (we ignore containers for this purpose).
active_prefixes = netaddr.cidr_merge([p.prefix for p in queryset.filter(status=PREFIX_STATUS_ACTIVE)])
reserved_prefixes = netaddr.cidr_merge([p.prefix for p in queryset.filter(status=PREFIX_STATUS_RESERVED)])
deprecated_prefixes = netaddr.cidr_merge([p.prefix for p in queryset.filter(status=PREFIX_STATUS_DEPRECATED)])
# Find all available prefixes by subtracting each of the existing prefix sets from the aggregate prefix.
available_prefixes = (
netaddr.IPSet([aggregate.prefix]) -
netaddr.IPSet(active_prefixes) -
netaddr.IPSet(reserved_prefixes) -
netaddr.IPSet(deprecated_prefixes)
)
# Add the size of each metric to the RIR total.
stats['total'] += aggregate.prefix.size / denominator
stats['active'] += netaddr.IPSet(active_prefixes).size / denominator
stats['reserved'] += netaddr.IPSet(reserved_prefixes).size / denominator
stats['deprecated'] += netaddr.IPSet(deprecated_prefixes).size / denominator
stats['available'] += available_prefixes.size / denominator
# Calculate the percentage of total space for each prefix status.
total = float(stats['total'])
stats['percentages'] = {
'active': float('{:.2f}'.format(stats['active'] / total * 100)) if total else 0,
'reserved': float('{:.2f}'.format(stats['reserved'] / total * 100)) if total else 0,
'deprecated': float('{:.2f}'.format(stats['deprecated'] / total * 100)) if total else 0,
}
stats['percentages']['available'] = (
100 -
stats['percentages']['active'] -
stats['percentages']['reserved'] -
stats['percentages']['deprecated']
)
rir.stats = stats
rirs.append(rir)
return rirs
def extra_context(self):
totals = {
'total': sum([rir.stats['total'] for rir in self.queryset]),
'active': sum([rir.stats['active'] for rir in self.queryset]),
'reserved': sum([rir.stats['reserved'] for rir in self.queryset]),
'deprecated': sum([rir.stats['deprecated'] for rir in self.queryset]),
'available': sum([rir.stats['available'] for rir in self.queryset]),
}
return {
'totals': totals,
}
class RIREditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.change_rir'
model = RIR
form_class = forms.RIRForm
success_url = 'ipam:rir_list'
cancel_url = 'ipam:rir_list'
def get_return_url(self, obj):
return reverse('ipam:rir_list')
class RIRBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_rir'
cls = RIR
default_redirect_url = 'ipam:rir_list'
filter = filters.RIRFilter
default_return_url = 'ipam:rir_list'
#
@@ -179,7 +266,6 @@ class AggregateListView(ObjectListView):
filter = filters.AggregateFilter
filter_form = forms.AggregateFilterForm
table = tables.AggregateTable
edit_permissions = ['ipam.change_aggregate', 'ipam.delete_aggregate']
template_name = 'ipam/aggregate_list.html'
def extra_context(self):
@@ -208,14 +294,26 @@ 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)
paginate = {
'klass': EnhancedPaginator,
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
}
RequestConfig(request, paginate).configure(prefix_table)
# Compile permissions list for rendering the object table
permissions = {
'add': request.user.has_perm('ipam.add_prefix'),
'change': request.user.has_perm('ipam.change_prefix'),
'delete': request.user.has_perm('ipam.delete_prefix'),
}
return render(request, 'ipam/aggregate.html', {
'aggregate': aggregate,
'prefix_table': prefix_table,
'permissions': permissions,
})
@@ -224,13 +322,13 @@ class AggregateEditView(PermissionRequiredMixin, ObjectEditView):
model = Aggregate
form_class = forms.AggregateForm
template_name = 'ipam/aggregate_edit.html'
cancel_url = 'ipam:aggregate_list'
default_return_url = 'ipam:aggregate_list'
class AggregateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_aggregate'
model = Aggregate
redirect_url = 'ipam:aggregate_list'
default_return_url = 'ipam:aggregate_list'
class AggregateBulkImportView(PermissionRequiredMixin, BulkImportView):
@@ -238,21 +336,23 @@ class AggregateBulkImportView(PermissionRequiredMixin, BulkImportView):
form = forms.AggregateImportForm
table = tables.AggregateTable
template_name = 'ipam/aggregate_import.html'
obj_list_url = 'ipam:aggregate_list'
default_return_url = 'ipam:aggregate_list'
class AggregateBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'ipam.change_aggregate'
cls = Aggregate
filter = filters.AggregateFilter
form = forms.AggregateBulkEditForm
template_name = 'ipam/aggregate_bulk_edit.html'
default_redirect_url = 'ipam:aggregate_list'
default_return_url = 'ipam:aggregate_list'
class AggregateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_aggregate'
cls = Aggregate
default_redirect_url = 'ipam:aggregate_list'
filter = filters.AggregateFilter
default_return_url = 'ipam:aggregate_list'
#
@@ -262,7 +362,6 @@ class AggregateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class RoleListView(ObjectListView):
queryset = Role.objects.all()
table = tables.RoleTable
edit_permissions = ['ipam.change_role', 'ipam.delete_role']
template_name = 'ipam/role_list.html'
@@ -270,14 +369,15 @@ class RoleEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.change_role'
model = Role
form_class = forms.RoleForm
success_url = 'ipam:role_list'
cancel_url = 'ipam:role_list'
def get_return_url(self, obj):
return reverse('ipam:role_list')
class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_role'
cls = Role
default_redirect_url = 'ipam:role_list'
default_return_url = 'ipam:role_list'
#
@@ -285,11 +385,10 @@ 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
edit_permissions = ['ipam.change_prefix', 'ipam.delete_prefix']
template_name = 'ipam/prefix_list.html'
def alter_queryset(self, request):
@@ -300,7 +399,9 @@ class PrefixListView(ObjectListView):
def prefix(request, pk):
prefix = get_object_or_404(Prefix.objects.select_related('site', 'vlan', 'role'), pk=pk)
prefix = get_object_or_404(Prefix.objects.select_related(
'vrf', 'site__region', 'tenant__group', 'vlan__group', 'role'
), pk=pk)
try:
aggregate = Aggregate.objects.get(prefix__net_contains_or_equals=str(prefix.prefix))
@@ -308,7 +409,7 @@ def prefix(request, pk):
aggregate = None
# Count child IP addresses
ipaddress_count = IPAddress.objects.filter(vrf=prefix.vrf, address__net_contained_or_equal=str(prefix.prefix))\
ipaddress_count = IPAddress.objects.filter(vrf=prefix.vrf, address__net_host_contained=str(prefix.prefix))\
.count()
# Parent prefixes table
@@ -316,28 +417,35 @@ def prefix(request, pk):
.filter(prefix__net_contains=str(prefix.prefix))\
.select_related('site', 'role').annotate_depth()
parent_prefix_table = tables.PrefixBriefTable(parent_prefixes)
parent_prefix_table.exclude = ('vrf',)
# Duplicate prefixes table
duplicate_prefixes = Prefix.objects.filter(vrf=prefix.vrf, prefix=str(prefix.prefix)).exclude(pk=prefix.pk)\
.select_related('site', 'role')
duplicate_prefix_table = tables.PrefixBriefTable(duplicate_prefixes)
duplicate_prefix_table = tables.PrefixBriefTable(list(duplicate_prefixes))
duplicate_prefix_table.exclude = ('vrf',)
# Child prefixes table
if prefix.vrf:
# If the prefix is in a VRF, show child prefixes only within that VRF.
child_prefixes = Prefix.objects.filter(vrf=prefix.vrf)
else:
# If the prefix is in the global table, show child prefixes from all VRFs.
child_prefixes = Prefix.objects.all()
child_prefixes = child_prefixes.filter(prefix__net_contained=str(prefix.prefix))\
child_prefixes = Prefix.objects.filter(vrf=prefix.vrf, prefix__net_contained=str(prefix.prefix))\
.select_related('site', 'role').annotate_depth(limit=0)
if child_prefixes:
child_prefixes = add_available_prefixes(prefix.prefix, child_prefixes)
child_prefix_table = tables.PrefixTable(child_prefixes)
child_prefix_table.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)
paginate = {
'klass': EnhancedPaginator,
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
}
RequestConfig(request, paginate).configure(child_prefix_table)
# Compile permissions list for rendering the object table
permissions = {
'add': request.user.has_perm('ipam.add_prefix'),
'change': request.user.has_perm('ipam.change_prefix'),
'delete': request.user.has_perm('ipam.delete_prefix'),
}
return render(request, 'ipam/prefix.html', {
'prefix': prefix,
@@ -346,6 +454,8 @@ def prefix(request, pk):
'parent_prefix_table': parent_prefix_table,
'child_prefix_table': child_prefix_table,
'duplicate_prefix_table': duplicate_prefix_table,
'permissions': permissions,
'return_url': prefix.get_absolute_url(),
})
@@ -355,13 +465,14 @@ class PrefixEditView(PermissionRequiredMixin, ObjectEditView):
form_class = forms.PrefixForm
template_name = 'ipam/prefix_edit.html'
fields_initial = ['vrf', 'tenant', 'site', 'prefix', 'vlan']
cancel_url = 'ipam:prefix_list'
default_return_url = 'ipam:prefix_list'
class PrefixDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_prefix'
model = Prefix
redirect_url = 'ipam:prefix_list'
template_name = 'ipam/prefix_delete.html'
default_return_url = 'ipam:prefix_list'
class PrefixBulkImportView(PermissionRequiredMixin, BulkImportView):
@@ -369,21 +480,23 @@ class PrefixBulkImportView(PermissionRequiredMixin, BulkImportView):
form = forms.PrefixImportForm
table = tables.PrefixTable
template_name = 'ipam/prefix_import.html'
obj_list_url = 'ipam:prefix_list'
default_return_url = 'ipam:prefix_list'
class PrefixBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'ipam.change_prefix'
cls = Prefix
filter = filters.PrefixFilter
form = forms.PrefixBulkEditForm
template_name = 'ipam/prefix_bulk_edit.html'
default_redirect_url = 'ipam:prefix_list'
default_return_url = 'ipam:prefix_list'
class PrefixBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_prefix'
cls = Prefix
default_redirect_url = 'ipam:prefix_list'
filter = filters.PrefixFilter
default_return_url = 'ipam:prefix_list'
def prefix_ipaddresses(request, pk):
@@ -391,19 +504,31 @@ def prefix_ipaddresses(request, pk):
prefix = get_object_or_404(Prefix.objects.all(), pk=pk)
# Find all IPAddresses belonging to this Prefix
ipaddresses = IPAddress.objects.filter(vrf=prefix.vrf, address__net_contained_or_equal=str(prefix.prefix))\
ipaddresses = IPAddress.objects.filter(vrf=prefix.vrf, address__net_host_contained=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)
paginate = {
'klass': EnhancedPaginator,
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
}
RequestConfig(request, paginate).configure(ip_table)
# Compile permissions list for rendering the object table
permissions = {
'add': request.user.has_perm('ipam.add_ipaddress'),
'change': request.user.has_perm('ipam.change_ipaddress'),
'delete': request.user.has_perm('ipam.delete_ipaddress'),
}
return render(request, 'ipam/prefix_ipaddresses.html', {
'prefix': prefix,
'ip_table': ip_table,
'permissions': permissions,
})
@@ -416,7 +541,6 @@ class IPAddressListView(ObjectListView):
filter = filters.IPAddressFilter
filter_form = forms.IPAddressFilterForm
table = tables.IPAddressTable
edit_permissions = ['ipam.change_ipaddress', 'ipam.delete_ipaddress']
template_name = 'ipam/ipaddress_list.html'
@@ -425,18 +549,20 @@ def ipaddress(request, pk):
ipaddress = get_object_or_404(IPAddress.objects.select_related('interface__device'), pk=pk)
# Parent prefixes table
parent_prefixes = Prefix.objects.filter(vrf=ipaddress.vrf, prefix__net_contains=str(ipaddress.address.ip))
parent_prefixes_table = tables.PrefixBriefTable(parent_prefixes)
parent_prefixes = Prefix.objects.filter(vrf=ipaddress.vrf, prefix__net_contains=str(ipaddress.address.ip))\
.select_related('site', 'role')
parent_prefixes_table = tables.PrefixBriefTable(list(parent_prefixes))
parent_prefixes_table.exclude = ('vrf',)
# Duplicate IPs table
duplicate_ips = IPAddress.objects.filter(vrf=ipaddress.vrf, address=str(ipaddress.address))\
.exclude(pk=ipaddress.pk).select_related('interface__device', 'nat_inside')
duplicate_ips_table = tables.IPAddressBriefTable(duplicate_ips)
duplicate_ips_table = tables.IPAddressBriefTable(list(duplicate_ips))
# Related IP table
related_ips = IPAddress.objects.select_related('interface__device').exclude(address=str(ipaddress.address))\
.filter(vrf=ipaddress.vrf, address__net_contained_or_equal=str(ipaddress.address))
related_ips_table = tables.IPAddressBriefTable(related_ips)
related_ips_table = tables.IPAddressBriefTable(list(related_ips))
return render(request, 'ipam/ipaddress.html', {
'ipaddress': ipaddress,
@@ -446,19 +572,96 @@ 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:
assert False, form.errors
else:
form = forms.IPAddressAssignForm()
return render(request, 'ipam/ipaddress_assign.html', {
'ipaddress': ipaddress,
'form': form,
'return_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,
'return_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'
default_return_url = 'ipam:ipaddress_list'
class IPAddressDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_ipaddress'
model = IPAddress
redirect_url = 'ipam:ipaddress_list'
default_return_url = 'ipam:ipaddress_list'
class IPAddressBulkAddView(PermissionRequiredMixin, BulkAddView):
permission_required = 'ipam.add_ipaddress'
form = forms.IPAddressBulkAddForm
model = IPAddress
template_name = 'ipam/ipaddress_bulk_add.html'
default_return_url = 'ipam:ipaddress_list'
class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView):
@@ -466,20 +669,18 @@ class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView):
form = forms.IPAddressImportForm
table = tables.IPAddressTable
template_name = 'ipam/ipaddress_import.html'
obj_list_url = 'ipam:ipaddress_list'
default_return_url = 'ipam:ipaddress_list'
def save_obj(self, obj):
obj.save()
# Update primary IP for device if needed
# Update primary IP for device if needed. The Device must be updated directly in the database; otherwise we risk
# overwriting a previous IP assignment from the same import (see #861).
try:
if obj.family == 4 and obj.primary_ip4_for:
device = obj.primary_ip4_for
device.primary_ip4 = obj
device.save()
Device.objects.filter(pk=obj.primary_ip4_for.pk).update(primary_ip4=obj)
elif obj.family == 6 and obj.primary_ip6_for:
device = obj.primary_ip6_for
device.primary_ip6 = obj
device.save()
Device.objects.filter(pk=obj.primary_ip6_for.pk).update(primary_ip6=obj)
except Device.DoesNotExist:
pass
@@ -487,15 +688,17 @@ class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView):
class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'ipam.change_ipaddress'
cls = IPAddress
filter = filters.IPAddressFilter
form = forms.IPAddressBulkEditForm
template_name = 'ipam/ipaddress_bulk_edit.html'
default_redirect_url = 'ipam:ipaddress_list'
default_return_url = 'ipam:ipaddress_list'
class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_ipaddress'
cls = IPAddress
default_redirect_url = 'ipam:ipaddress_list'
filter = filters.IPAddressFilter
default_return_url = 'ipam:ipaddress_list'
#
@@ -507,7 +710,6 @@ class VLANGroupListView(ObjectListView):
filter = filters.VLANGroupFilter
filter_form = forms.VLANGroupFilterForm
table = tables.VLANGroupTable
edit_permissions = ['ipam.change_vlangroup', 'ipam.delete_vlangroup']
template_name = 'ipam/vlangroup_list.html'
@@ -515,14 +717,16 @@ class VLANGroupEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.change_vlangroup'
model = VLANGroup
form_class = forms.VLANGroupForm
success_url = 'ipam:vlangroup_list'
cancel_url = 'ipam:vlangroup_list'
def get_return_url(self, obj):
return reverse('ipam:vlangroup_list')
class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_vlangroup'
cls = VLANGroup
default_redirect_url = 'ipam:vlangroup_list'
filter = filters.VLANGroupFilter
default_return_url = 'ipam:vlangroup_list'
#
@@ -530,19 +734,19 @@ 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
edit_permissions = ['ipam.change_vlan', 'ipam.delete_vlan']
template_name = 'ipam/vlan_list.html'
def vlan(request, pk):
vlan = get_object_or_404(VLAN.objects.select_related('site', 'role'), pk=pk)
prefixes = Prefix.objects.filter(vlan=vlan)
prefix_table = tables.PrefixBriefTable(prefixes)
vlan = get_object_or_404(VLAN.objects.select_related('site__region', 'tenant__group', 'role'), pk=pk)
prefixes = Prefix.objects.filter(vlan=vlan).select_related('vrf', 'site', 'role')
prefix_table = tables.PrefixBriefTable(list(prefixes))
prefix_table.exclude = ('vlan',)
return render(request, 'ipam/vlan.html', {
'vlan': vlan,
@@ -555,13 +759,13 @@ class VLANEditView(PermissionRequiredMixin, ObjectEditView):
model = VLAN
form_class = forms.VLANForm
template_name = 'ipam/vlan_edit.html'
cancel_url = 'ipam:vlan_list'
default_return_url = 'ipam:vlan_list'
class VLANDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_vlan'
model = VLAN
redirect_url = 'ipam:vlan_list'
default_return_url = 'ipam:vlan_list'
class VLANBulkImportView(PermissionRequiredMixin, BulkImportView):
@@ -569,18 +773,44 @@ class VLANBulkImportView(PermissionRequiredMixin, BulkImportView):
form = forms.VLANImportForm
table = tables.VLANTable
template_name = 'ipam/vlan_import.html'
obj_list_url = 'ipam:vlan_list'
default_return_url = 'ipam:vlan_list'
class VLANBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'ipam.change_vlan'
cls = VLAN
filter = filters.VLANFilter
form = forms.VLANBulkEditForm
template_name = 'ipam/vlan_bulk_edit.html'
default_redirect_url = 'ipam:vlan_list'
default_return_url = 'ipam:vlan_list'
class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_vlan'
cls = VLAN
default_redirect_url = 'ipam:vlan_list'
filter = filters.VLANFilter
default_return_url = 'ipam:vlan_list'
#
# 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, request, url_args, url_kwargs):
if 'device' in url_kwargs:
obj.device = get_object_or_404(Device, pk=url_kwargs['device'])
return obj
def get_return_url(self, obj):
return obj.device.get_absolute_url()
class ServiceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_service'
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

@@ -6,13 +6,13 @@ from django.contrib.messages import constants as messages
from django.core.exceptions import ImproperlyConfigured
try:
import configuration
from netbox import configuration
except ImportError:
raise ImproperlyConfigured("Configuration file is not present. Please define netbox/netbox/configuration.py per "
"the documentation.")
VERSION = '1.6.0'
VERSION = '1.9.5'
# 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', '')
@@ -47,7 +50,7 @@ CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS
# Attempt to import LDAP configuration if it has been defined
LDAP_IGNORE_CERT_ERRORS = False
try:
from ldap_config import *
from netbox.ldap_config import *
LDAP_CONFIGURED = True
except ImportError:
LDAP_CONFIGURED = False
@@ -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__)))
@@ -101,6 +104,7 @@ INSTALLED_APPS = (
'django.contrib.humanize',
'debug_toolbar',
'django_tables2',
'mptt',
'rest_framework',
'rest_framework_swagger',
'circuits',
@@ -114,7 +118,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 +164,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 +178,7 @@ MESSAGE_TAGS = {
}
# Authentication URLs
LOGIN_URL = '/login/'
LOGIN_REDIRECT_URL = '/'
LOGIN_URL = '/{}login/'.format(BASE_PATH)
# Secrets
SECRETS_MIN_PUBKEY_SIZE = 2048
@@ -183,11 +187,14 @@ 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]),
}
# Django debug toolbar
INTERNAL_IPS = (
'127.0.0.1',
'::1',
)
try:

View File

@@ -1,14 +1,17 @@
from rest_framework_swagger.views import get_swagger_view
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 netbox.views import home, handle_500, trigger_500
from users.views import login, logout
handler500 = handle_500
swagger_view = get_swagger_view(title='NetBox API')
urlpatterns = [
_patterns = [
# Default page
url(r'^$', home, name='home'),
@@ -23,7 +26,7 @@ urlpatterns = [
url(r'^ipam/', include('ipam.urls', namespace='ipam')),
url(r'^secrets/', include('secrets.urls', namespace='secrets')),
url(r'^tenancy/', include('tenancy.urls', namespace='tenancy')),
url(r'^profile/', include('users.urls', namespace='users')),
url(r'^user/', include('users.urls', namespace='user')),
# API
url(r'^api/circuits/', include('circuits.api.urls', namespace='circuits-api')),
@@ -31,14 +34,24 @@ urlpatterns = [
url(r'^api/ipam/', include('ipam.api.urls', namespace='ipam-api')),
url(r'^api/secrets/', include('secrets.api.urls', namespace='secrets-api')),
url(r'^api/tenancy/', include('tenancy.api.urls', namespace='tenancy-api')),
url(r'^api/docs/', include('rest_framework_swagger.urls')),
url(r'^api/docs/', swagger_view, name='api_docs'),
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))
]

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