Compare commits

..

212 Commits

Author SHA1 Message Date
Jeremy Stretch
c1ef87e009 Merge pull request #4321 from netbox-community/develop
Release v2.7.9
2020-03-06 11:23:42 -05:00
Jeremy Stretch
09298dab7a Release v2.7.9 2020-03-06 11:17:17 -05:00
Jeremy Stretch
afcd9b801f Delete squashed migration to avoid 'pending trigger events' exception under certain conditions 2020-03-06 10:32:12 -05:00
Jeremy Stretch
12bedac28a Tweak upgrade script to exit immediately if any individual tasks fail 2020-03-06 10:26:12 -05:00
kobayashi
9f8d1d2436 Merge pull request #4063 from deathbeam/issue-4062
List choices for choice fields as enums in Swagger openapi spec
2020-03-05 23:58:43 -05:00
kobayashi
920078a738 set fix#4062 to release note 2020-03-05 23:52:33 -05:00
Tomas Slusny
4136a5fd5e List choices for choice fields as enums
Fixes #4062

Signed-off-by: Tomas Slusny <slusnucky@gmail.com>
2020-03-05 23:44:37 -05:00
Jeremy Stretch
aefed0df89 Add user survey note to README 2020-03-05 08:51:03 -05:00
Jeremy Stretch
e4cfeb1977 Add reminder to reload systemctl daemon if needed 2020-03-04 17:19:16 -05:00
Jeremy Stretch
e4e4af1b2d Delete obsolete old_requirements.txt 2020-03-04 16:07:19 -05:00
Jeremy Stretch
f7c6df6e6a Improved verbosity of upgrade script 2020-03-04 12:20:06 -05:00
Jeremy Stretch
9e92520266 Changelog for #4121 2020-03-04 11:53:53 -05:00
Jeremy Stretch
1dd07337fd Merge pull request #4189 from netbox-community/4121-filter-lookup-expressions
4121 filter lookup expressions
2020-03-04 11:51:19 -05:00
Jeremy Stretch
746bfd8bca Closes #4119: Extend upgrade script to clear expired user sessions 2020-03-04 10:29:22 -05:00
Jeremy Parker
10dee9b57b Fix typo in caching.md (#4310)
Co-authored-by: Jeremy Stretch <jeremy.stretch@networktocode.com>
2020-03-04 10:07:58 -05:00
Jeremy Stretch
31e5d9ffe6 Changelog for #4298 2020-03-04 10:03:39 -05:00
Jeremy Stretch
a45b18b335 Merge pull request #4311 from netbox-community/4298-custom-fields-api
Fixes #4298: Bulk creation of objects with custom fields via API
2020-03-04 10:02:23 -05:00
Jeremy Stretch
57b6ac7cb1 Finish work on improved custom fields API tests 2020-03-04 09:53:49 -05:00
Jeremy Stretch
4611536ca9 Revise custom field API tests to check for single/multiple objects with/without custom field values 2020-03-03 17:07:43 -05:00
Jeremy Stretch
e4abfd192e Introduce CustomFieldDefaultValues class to handle default custom field values 2020-03-03 14:54:48 -05:00
Jeremy Stretch
cb7932ecda Merge pull request #4308 from kobayashi/4229-docs-python-version
Fix #4229: Describe supported Python version
2020-03-03 12:14:10 -05:00
Jeremy Stretch
e4fc37e91a Merge pull request #4304 from dstarner/4288-incorrect-swagger-type
Set local_context_data serializer on Device and VM to method
2020-03-03 12:13:06 -05:00
Jeremy Stretch
74821c2c17 Closes #4218: Allow negative voltage for DC power feeds 2020-03-03 12:10:08 -05:00
Dan Starner
e6ee9803d4 use FieldInspector for JSONField type 2020-03-03 12:04:46 -05:00
kobayashi
fa992853b0 Describe supported Python version 2020-03-03 10:52:56 -05:00
Jeremy Stretch
1e4dd102bd Closes #4290: Include device name in tooltip on rack elevations 2020-03-03 10:35:07 -05:00
Jeremy Stretch
0f9c37fbc7 Closes #4284: Add MRJ21 port and cable types 2020-03-03 10:13:21 -05:00
Jeremy Stretch
a261060e5c Closes #4305: Add 10-inch option for rack width 2020-03-03 10:07:20 -05:00
Jeremy Stretch
78a1aad6c0 Fixes #4306: Fix toggling of device images for all racks in elevations view 2020-03-03 10:04:35 -05:00
Jeremy Stretch
7b6bd75c22 #4295 follow-up: Tweak handling of initial data for bulk edit forms 2020-03-03 09:43:56 -05:00
Daniel Starner
3070c7e991 fix linting mistake 2020-03-02 15:45:58 -05:00
Dan Starner
406708218b set local_context_data serializer on device and vm to method 2020-03-02 13:47:07 -05:00
Jeremy Stretch
c7b74b2090 Fixes #4300: Pass "commit" argument when executing scripts via REST API 2020-03-02 13:29:32 -05:00
Jeremy Stretch
1b38f3ad3a Merge pull request #4303 from kobayashi/4274-prefix-schema
Fix #4274: Set correct value schema for int typed ChoiceField
2020-03-02 13:14:04 -05:00
Jeremy Stretch
1e1c6526b2 Add BaseFilterSetTest to validate automatic generation of filters 2020-03-02 12:25:21 -05:00
kobayashi
0be5488104 Fix #4274: Set correct value schema for int typed ChoiceField 2020-03-02 11:26:56 -05:00
Jeremy Stretch
13fcdc0c1e Fixes #4301: Fix exception when deleting device type with components 2020-03-02 09:41:37 -05:00
John Anderson
e5f8f15293 added lookup map for treenode filter 2020-02-28 19:58:06 -05:00
Jeremy Stretch
22ec11c766 Fix up CentOS installation instructions; add troubleshooting guide 2020-02-28 17:31:51 -05:00
Jeremy Stretch
1b6f721e50 Extend installation docs to include creation of a specific netbox user 2020-02-28 16:25:43 -05:00
Jeremy Stretch
9c88f12abe Add note about Redis config to installation instructions 2020-02-28 15:58:55 -05:00
Jeremy Stretch
e285d0b547 Remove obsolete querydict_to_dict() utility function 2020-02-28 15:47:59 -05:00
Jeremy Stretch
1449dfc966 Fixes #4295: Fix assignment of parent LAG during interface bulk edit 2020-02-28 09:44:41 -05:00
Jeremy Stretch
bdf9857e6f Closes #4294: Add tenancy filter tests for all models 2020-02-27 13:28:08 -05:00
Jeremy Stretch
faf676e6e0 Closes #4281: Allow filtering device component list views by type 2020-02-26 17:22:53 -05:00
Jeremy Stretch
624566b04e Fixes #4282: Fix label on export button for device types 2020-02-26 16:49:39 -05:00
Jeremy Stretch
5a00939512 Fixes #4285: Include A/Z termination sites in provider circuits table 2020-02-26 16:41:59 -05:00
Jeremy Stretch
4431259cd8 Merge pull request #4287 from netbox-community/3949-virtualenv-install
Closes #3949: Revise installation docs to use a Python virtual environment
2020-02-26 16:04:51 -05:00
Jeremy Stretch
87c914bece Reorganize .gitignore; add /venv/ 2020-02-26 15:59:26 -05:00
Jeremy Stretch
56c26f80b3 Changelog for #3949 2020-02-26 15:56:26 -05:00
Jeremy Stretch
215dbef7a0 Improved formatting of upgrade script messages 2020-02-26 15:40:05 -05:00
Jeremy Stretch
64c9bf27c1 Fix exit statement in upgrade script 2020-02-26 15:34:42 -05:00
Jeremy Stretch
1abc82e718 Update upgrade script & instructions to better accomodate moving to a venv 2020-02-26 15:26:12 -05:00
Jeremy Stretch
be9df3c07d Simplify the systemctl commands 2020-02-26 14:38:25 -05:00
Jeremy Stretch
798ecfc8f0 Clean up the upgrade guide 2020-02-26 14:09:54 -05:00
Jeremy Stretch
015a339202 Update systemd migration guide 2020-02-26 14:09:27 -05:00
Jeremy Stretch
2ee06c13f9 Remove Python 2 migration guide 2020-02-26 13:56:44 -05:00
Jeremy Stretch
0851b97ba5 Update the upgrade script & instructions to use a virtual environment 2020-02-26 13:54:07 -05:00
Jeremy Stretch
1b64f67f2b Update installation instructions to use a Python virtual environment 2020-02-26 12:28:35 -05:00
John Anderson
c908f132ec Merge branch 'develop' into 4121-filter-lookup-expressions 2020-02-26 12:05:27 -05:00
Jeremy Stretch
c78df40cb0 Refactor installtion docs 2020-02-26 11:40:31 -05:00
Jeremy Stretch
5a61bbec26 Fixes #4277: Fix filtering of clusters by tenant 2020-02-26 09:11:38 -05:00
John Anderson
3b4607d30d refactor lookup map logic 2020-02-25 15:16:27 -05:00
Jeremy Stretch
5befe66aa5 Post-release version bump 2020-02-25 15:11:04 -05:00
Jeremy Stretch
3c249a40a0 Merge pull request #4275 from netbox-community/develop
Release v2.7.8
2020-02-25 15:09:41 -05:00
Jeremy Stretch
7e81d5fe11 Release v2.7.8 2020-02-25 15:04:08 -05:00
Jeremy Stretch
6b4858303b Fix object list table width when no filter_form is present 2020-02-25 15:03:27 -05:00
Jeremy Stretch
5000f7f8d7 Extend custom scripts to pass the 'commit' value via run() 2020-02-25 14:49:41 -05:00
Jeremy Stretch
f643af13d7 Fix field label for Prefix.vrf 2020-02-25 14:12:53 -05:00
John Anderson
afc8c9bfe9 fix tenancy filterset bases 2020-02-25 13:50:31 -05:00
Jeremy Stretch
a6e859d9b7 Remove extraneous prefetches from racks queryset 2020-02-25 12:35:27 -05:00
Jeremy Stretch
5bf68493df Changelog & cleanup for #4267 2020-02-25 12:34:48 -05:00
Jeremy Stretch
43f3488270 Merge pull request #4271 from ananace/4267-rack-roles-in-elevations
Fixes #4267: Display rack roles in elevation list view
2020-02-25 12:18:31 -05:00
Jeremy Stretch
62efe0621f Fixes #4272: Interface type should be required by API serializer 2020-02-25 11:20:43 -05:00
Jeremy Stretch
161f03217e Merge pull request #4269 from hSaria/4227-omit-private-changelog
Fixes #4227: Omit internal fields from the change log data
2020-02-25 11:02:22 -05:00
Jeremy Stretch
40b56d7f62 Merge branch 'develop' into 4227-omit-private-changelog 2020-02-25 11:02:11 -05:00
Jeremy Stretch
4eb731cfae Merge pull request #4263 from netbox-community/4237-custom-webhooks
Closes #4237: Enable custom templating for webhook request content
2020-02-25 10:54:30 -05:00
Jeremy Stretch
35786966c6 Changelog for #4237 2020-02-25 10:46:16 -05:00
Jeremy Stretch
c3b64164ba Always use a JSON object to convey change data when no body template is present 2020-02-25 10:43:14 -05:00
Jeremy Stretch
644b4aa42d Revised webhook documentation 2020-02-25 10:24:27 -05:00
Alexander Olofsson
838291c3f3 Display rack roles in elevation list view 2020-02-25 16:00:21 +01:00
Saria Hajjar
3296298d21 Fixes #4227: Omit internal fields from the change log data 2020-02-25 14:48:11 +00:00
Jeremy Stretch
211311be9f Add http_method field to Webhook 2020-02-24 20:42:24 -05:00
Jeremy Stretch
9a532b1eb2 Extend templatization ability to additional_headers field 2020-02-24 17:47:17 -05:00
Jeremy Stretch
1fbd3a2c26 Convert additional_headers to a TextField 2020-02-24 16:59:35 -05:00
Jeremy Stretch
99038ffc44 Enable custom templating for webhook request content 2020-02-24 16:12:46 -05:00
John Anderson
67565ca191 added docs and more tests 2020-02-24 15:03:07 -05:00
Jeremy Stretch
81d001d49e Add a note about X-Frame-Options to the HTTP daemon instructions 2020-02-24 14:30:03 -05:00
Jeremy Stretch
cc4a22b2d1 Merge pull request #4261 from netbox-community/3145-cable-status-decommissioning
Closes #3145: Add a "decommissioning" status for cables
2020-02-24 14:24:34 -05:00
Jeremy Stretch
4d1749cc71 Remove CONNECTION_STATUS_CONNECTED and CONNECTION_STATUS_PLANNED constants 2020-02-24 14:18:19 -05:00
Jeremy Stretch
f7b620c6a2 Closes #3145: Add 'decommissioning' status for cables 2020-02-24 14:09:36 -05:00
Jeremy Stretch
76138f3080 Fixes #4222: Escape double quotes on encapsulated values during CSV export 2020-02-24 13:29:00 -05:00
Jeremy Stretch
36f8d6d259 Disconnect post_save & pre_delete signals after the response has been received 2020-02-24 12:42:51 -05:00
Jeremy Stretch
909971f237 Fixes #4221: Fix exception when deleting a device with interface connections when an interfaces webhook is defined 2020-02-24 12:41:55 -05:00
Jeremy Stretch
4e76e4ec9c Merge pull request #4253 from hSaria/3612-update-screenshots
Fixes #3612: Update screenshots
2020-02-24 11:45:15 -05:00
Saria Hajjar
87d90adaef Downscaled pictures to 50% (~1600 x ~1000) 2020-02-24 16:41:32 +00:00
Jeremy Stretch
cfd813772d Merge pull request #4257 from netbox-community/4250-extend-view-tests
Extend ViewTestCases for create/edit/delete/import views
2020-02-24 11:30:01 -05:00
Jeremy Stretch
2b484955aa Extend ViewTestCases for create/edit/delete/import views to also check non-data-bound GET requests 2020-02-24 11:23:42 -05:00
dansheps
d9dcc92300 Update release notes for #4230 2020-02-24 09:31:52 -06:00
Daniel Sheppard
d0c82c23bd Merge pull request #4244 from netbox-community/4230-filter_rack_unit_on_elevations
Fixes: #4230 - Fixes rack units filtering on elevation endpoint
2020-02-24 09:25:42 -06:00
Saria Hajjar
6cdb86931e Added separators between screenshots 2020-02-24 15:22:33 +00:00
Saria Hajjar
23d0241865 Updated screenshots with v2.7.7 2020-02-24 15:21:17 +00:00
Jeremy Stretch
61c0a4cc61 Fixes #4252: Fix power port assignment for power outlet templates created via REST API 2020-02-24 10:13:47 -05:00
Jeremy Stretch
b97d3b0716 Fixes #4246: Fix duplication of field attributes when multiple IPNetworkVars are present in a script 2020-02-24 10:01:31 -05:00
Jeremy Stretch
25d126d4ff Call prepare_value() to avoid passing model instances directly to the filterset 2020-02-24 09:31:31 -05:00
Dan Sheppard
2b75e05ea7 Fixes: #4230 - Fixes filtering by position on elevation endpoint
* Add tests for rack elevation filtering
* Add q variable to serializers for RackElevationDetailFilterSerializer
* Add code to allow filtering of position on the rack elevation
2020-02-22 08:24:26 -06:00
Jeremy Stretch
1a997610c7 Fixes #4241: Correct IP address hyperlinks on interface view 2020-02-21 21:43:04 -05:00
Jeremy Stretch
04ee55a40c Fixes #4240: Fix exception when filtering foreign keys by NULL 2020-02-21 21:38:25 -05:00
Jeremy Stretch
1c72d75b62 Fixes #4239: Fix exception when selecting all filtered objects during bulk edit 2020-02-21 20:44:53 -05:00
Jeremy Stretch
9128dc961c Closes #4173: Return graceful error message when webhook queuing fails 2020-02-21 17:21:04 -05:00
Jeremy Stretch
12602a95ea All fields on RenderedGraphSerializer should be read-only 2020-02-21 14:45:07 -05:00
Jeremy Stretch
11d012de4e Fixes #4235: Fix API representation of content_type for export templates 2020-02-21 14:38:38 -05:00
Jeremy Stretch
45cdac6f36 Changelog for #4228 2020-02-21 14:19:02 -05:00
Jeremy Stretch
2c4136f514 Merge pull request #4233 from netbox-community/4228-rack-elevation-images
Fixes #4228: Fix display of device images in rack elevations
2020-02-21 13:55:32 -05:00
Jeremy Stretch
a14c7980f6 Fixes #4232: Enforce consistent background striping in rack elevations 2020-02-21 13:49:28 -05:00
Jeremy Stretch
329740d2a7 Add "Save SVG" link beneath rack elevation display 2020-02-21 13:28:18 -05:00
Jeremy Stretch
8ffba6a279 Clean up rack view CSS 2020-02-21 13:19:45 -05:00
Jeremy Stretch
5a02dc457c Fixes #4228: Fit device images to rack unit; tweak default aspect ratio 2020-02-21 12:43:24 -05:00
Jeremy Stretch
cf312e9690 Changelog for #4224 2020-02-21 09:42:07 -05:00
Jeremy Stretch
20b36f910f Merge pull request #4225 from LuPo/develop
rear rack face doesn't draw if full-height device type assigned only front image
2020-02-21 09:40:23 -05:00
LuPo
0b3111c47f Fix drawing rear elevation when full-height device has been assigned only front image 2020-02-21 13:42:52 +02:00
Jeremy Stretch
493d68a57a Post-release version bump 2020-02-20 14:59:00 -05:00
Jeremy Stretch
5092641157 Merge pull request #4216 from netbox-community/develop
Release v2.7.7
2020-02-20 14:56:27 -05:00
Jeremy Stretch
2b134ea0f0 Release v2.7.7 2020-02-20 14:48:23 -05:00
Jeremy Stretch
58ff08be4e #1529: Add front/rear image fields to DeviceType serializer 2020-02-20 14:37:08 -05:00
Jeremy Stretch
682fd40fff Changelog for #4206 2020-02-20 14:27:26 -05:00
Jeremy Stretch
cdcc63fdf6 Merge pull request #4208 from wtinetworkgear/rj11add
Added serial console_port type rj-11 for a POTS modem connection
2020-02-20 14:26:24 -05:00
Jeremy Stretch
c0052eb416 Closes #4209: Enable filtering interfaces list view by enabled 2020-02-20 14:24:22 -05:00
Jeremy Stretch
74e3e2e5e1 Changelog for #4213 2020-02-20 14:17:18 -05:00
Jeremy Stretch
214470fb84 Rearrange powerfeed view 2020-02-20 14:16:18 -05:00
Jeremy Stretch
e76ea2a03c Merge pull request #4214 from ironick09/develop
Fix Power Feed page to render Custom Fields and Tags
2020-02-20 14:14:57 -05:00
Jeremy Stretch
6c272adb0e Clean up rack headers and border 2020-02-20 14:10:48 -05:00
Jeremy Stretch
51f7b7a5bf Changelog for #1529 2020-02-20 13:49:34 -05:00
Jeremy Stretch
b81622222d Merge pull request #4215 from netbox-community/1529-rack-elevation-images
Closes #1529: Rack elevation images
2020-02-20 13:48:44 -05:00
Jeremy Stretch
e8a8b39c47 Tweak gitignore to include devicetype-images directory 2020-02-20 13:42:23 -05:00
Jeremy Stretch
c78d30d47e Enable toggling of device images on elevations list 2020-02-20 13:20:58 -05:00
Jeremy Stretch
ba6562a5db Add ability to toggle the inclusion of device images when rendering a rack elevation SVG 2020-02-20 13:09:43 -05:00
Josh Niec
b28729baff Fix rendering for powerfeeds_list to show any added custom fields or tags on object
Signed-off-by: Josh Niec <ironick09@gmail.com>
2020-02-20 12:03:09 -06:00
Jeremy Stretch
adf9221bab Refactor rack elevation mixin to RackElevationSVG 2020-02-20 12:48:44 -05:00
Jeremy Stretch
d2157a3423 Add front/rear images for device types; include in rack elevations 2020-02-20 12:11:59 -05:00
Jeremy Stretch
e07ed3de93 Ignore media files 2020-02-20 11:22:04 -05:00
Jeremy Stretch
584539d0a3 #3810: Fix bug in Javascript 2020-02-20 10:20:19 -05:00
Jeremy Stretch
322b328584 Fixes #4211: Include trailing text when naturalizing interface names 2020-02-20 09:49:15 -05:00
Jeremy Stretch
b38eeaebc9 Clarify ciphertext length calculation; remove Python 2 compatibility 2020-02-19 21:14:56 -05:00
Jeremy Stretch
66fa79741d Add min/max length tests for secrets 2020-02-19 21:06:21 -05:00
Ken Partridge
09805ddc4a Added serial console_port type rj-11 for a POTS modem connection 2020-02-19 15:39:53 -08:00
Jeremy Stretch
1130f6b9f0 Remove dependency on RawSQL from IPAddress manager 2020-02-19 17:17:41 -05:00
Jeremy Stretch
7a53e24f97 Closes #3810: Preserve slug value when editing existing objects 2020-02-19 13:53:11 -05:00
Jeremy Stretch
f05c7be394 Fixes #4204: Fix assignment of mask length when bulk editing prefixes 2020-02-19 13:28:07 -05:00
Jeremy Stretch
2cf990bd92 Standardize on two-word form of "change log" 2020-02-19 12:45:52 -05:00
Jeremy Stretch
21473548a5 Merge pull request #4181 from hSaria/2511-change-diff
Fixes #2511: Compare object change to the previous change
2020-02-19 12:41:24 -05:00
Jeremy Stretch
626513a8b2 Fixes #4202: Prevent reassignment to master device when bulk editing VC member interfaces 2020-02-19 11:29:42 -05:00
Jeremy Stretch
5871640701 Closes #4199: Update example report to use ChoiceSet 2020-02-19 10:31:10 -05:00
Jeremy Stretch
8cfb5ac5c6 Fixes #3967: Fix missing migration for interface templates of type "other" 2020-02-18 16:56:50 -05:00
Jeremy Stretch
ae1767b5d0 Remove obsolete InterfaceManager 2020-02-18 16:22:17 -05:00
Jeremy Stretch
84d078a539 Fixes #4196: Fix exception when viewing LLDP neighbors page 2020-02-18 16:21:50 -05:00
Jeremy Stretch
2a1de0202f Add helpful links to "new issue" page 2020-02-18 11:43:47 -05:00
Jeremy Stretch
4ea8967c2d Fixes #4194: Role field should not be required when searching/filtering secrets 2020-02-18 11:14:37 -05:00
Jeremy Stretch
a456cbb26c Fixes #4179: Site is required when creating a rack group or power panel 2020-02-18 11:08:16 -05:00
Jeremy Stretch
5b505b21c8 Fixes #4183: Fix representation of NaturalOrderingField values in change log 2020-02-18 10:50:14 -05:00
John Anderson
9116d74cf7 merge develop 2020-02-18 00:39:30 -05:00
John Anderson
a136a0788c #4121 - dynamic filter lookup expressions 2020-02-18 00:32:58 -05:00
Saria Hajjar
89ab6553d6 Changelog for #2511 2020-02-15 23:55:03 +00:00
Saria Hajjar
faa22cb637 Fixes #2511: Compare object change to the previous change 2020-02-15 22:39:08 +00:00
Jeremy Stretch
1a8eea5aa9 Fixes #4175: Fix potential exception when bulk editing objects from a filtered list 2020-02-14 14:27:47 -05:00
Jeremy Stretch
2de8d8b73d Merge pull request #4174 from netbox-community/4164-object-list-template
Closes #4164: Consolidate object list templates
2020-02-14 13:31:25 -05:00
Jeremy Stretch
440f754fec Clean up TODO notes 2020-02-14 13:30:53 -05:00
Jeremy Stretch
815a46bfbe Convert device and VM list views to use obj_list.html 2020-02-14 13:21:32 -05:00
Jeremy Stretch
182fddddd2 Merge branch 'develop' into 4164-object-list-template 2020-02-14 13:11:30 -05:00
Jeremy Stretch
ce89fa74b9 Closes #4170: Improve color contrast in rack elevation drawings 2020-02-14 13:09:01 -05:00
Jeremy Stretch
7ce1289bb2 Clean up unused imports 2020-02-14 12:04:56 -05:00
Jeremy Stretch
e4df02887b Changelog for #3840 2020-02-14 12:04:35 -05:00
Jeremy Stretch
6bc7be7ba5 Merge pull request #3925 from hSaria/3840-limit-vlan-choices
Fixes #3840: Only show valid interface VLAN choices
2020-02-14 11:48:29 -05:00
Saria Hajjar
7aba8e3ec4 Added back clean 2020-02-14 16:43:42 +00:00
Jeremy Stretch
8d5ea5d005 Merge pull request #4169 from dstarner/redis-sentinel-conn-check
[Corollary to #4161] Redis Connection Check when Using Sentinel
2020-02-14 11:43:04 -05:00
Dan Starner
ec0f45e20d remove redis conn check from extras AppConfig 2020-02-14 11:16:59 -05:00
Jeremy Stretch
0c8ad45976 Merge pull request #4172 from dstarner/4171-required-settings-documentation-format
[Fix #4171] fix extraneous formatting of notice boxes in required settings doc
2020-02-14 10:47:40 -05:00
Dan Starner
e431ef09e5 fix extraneous formatting of notice boxes in required settings doc 2020-02-14 10:29:09 -05:00
Dan Starner
03a7f6bbda ammend redis conn check to acccount for sentinel 2020-02-14 09:39:01 -05:00
Jeremy Stretch
a4705fa73a Changelog for #2519 2020-02-14 09:35:43 -05:00
Jeremy Stretch
0a8d39cfe4 Merge pull request #3726 from eSentire/fix-2519
Fix race condition in available-prefix/ip APIs
2020-02-14 09:32:51 -05:00
Jeremy Stretch
1d72436bfc Fixes #4168: Role is not required when creating a virtual machine 2020-02-14 09:13:05 -05:00
Jeremy Stretch
598d23fc03 Post-release version bump 2020-02-13 21:51:03 -05:00
Jeremy Stretch
8212c8f6fc Convert IPAM list views to extend standard template 2020-02-13 17:22:17 -05:00
Jeremy Stretch
8df9bb6fb4 Convert change log view to extend standard template 2020-02-13 17:11:39 -05:00
Jeremy Stretch
ff952fb221 Migrate extras views to use common object list template 2020-02-13 16:39:38 -05:00
John Anderson
9ead2635c5 merge develop 2020-02-13 16:00:07 -05:00
Jeremy Stretch
6884404957 Migrate virtualization views to use common object list template 2020-02-13 14:24:22 -05:00
Jeremy Stretch
88c917231d Migrate tenancy views to use common object list template 2020-02-13 14:21:14 -05:00
Jeremy Stretch
a054aff3c4 Migrate secrets views to use common object list template 2020-02-13 14:19:14 -05:00
Jeremy Stretch
8fd809ac5e Migrate IPAM views to use common object list template 2020-02-13 14:17:13 -05:00
Jeremy Stretch
fff657cd5a Migrate DCIM views to use common object list template 2020-02-13 14:07:15 -05:00
Jeremy Stretch
4ef15e4dc8 Migrate circuits views to use common object list template 2020-02-13 13:31:04 -05:00
Jeremy Stretch
c5f74cce80 Introduce a common template for object list views 2020-02-13 13:29:50 -05:00
Matt Olenik
2e83ce76ed Fix race condition in available-prefix/ip APIs
Implement advisory lock to prevent duplicate records being inserted
when making simultaneous calls. Fixes #2519
2020-02-11 13:36:52 -08:00
John Anderson
9284e83270 py3.5 compatibility 2020-02-09 21:32:45 -05:00
John Anderson
a6b43b30e9 functional dynamic filter lookups 2020-02-09 17:46:21 -05:00
John Anderson
a311002141 initial work on dynamic lookup expressions 2020-02-09 03:20:59 -05:00
Saria Hajjar
26ddd96e30 Cleaned duplicate code 2020-02-08 16:18:58 +00:00
Saria Hajjar
f0c83e168e Merge branch 'develop' into 3840-limit-vlan-choices 2020-02-08 16:14:10 +00:00
Saria Hajjar
ace8fac2c1 Removed changelog to avoid merge conflicts 2020-01-30 18:29:08 +00:00
Saria Hajjar
ae95b159bc Virtualization interfaces VLAN filtering 2020-01-30 18:26:30 +00:00
Saria Hajjar
ff822743cc Corrected linter warning 2020-01-30 18:10:39 +00:00
hSaria
deb653cbf3 Merge branch 'develop' into 3840-limit-vlan-choices 2020-01-24 20:54:56 +00:00
hSaria
2684f86594 Merge branch 'develop' into 3840-limit-vlan-choices 2020-01-21 21:27:42 +00:00
hSaria
f052b90ec3 Merge branch 'develop' into 3840-limit-vlan-choices 2020-01-17 11:42:15 +00:00
hSaria
a30e50ecc4 Merge branch 'develop' into 3840-limit-vlan-choices 2020-01-16 22:45:23 +00:00
Saria Hajjar
c31c8b1a25 Moved into v2.7.1 2020-01-16 21:51:37 +00:00
Saria Hajjar
c8997868ce Added #3840 changelog 2020-01-16 15:10:25 +00:00
Saria Hajjar
02cf39c85b Merge branch 'develop' into 3840-limit-vlan-choices 2020-01-16 15:09:39 +00:00
Saria Hajjar
201416ba52 Semicolons for completeness 2020-01-15 12:38:09 +00:00
Saria Hajjar
9d846d7b87 Fixes #3840: Only show valid interface VLAN choices 2020-01-15 12:23:34 +00:00
205 changed files with 3628 additions and 3258 deletions

9
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,9 @@
# Reference: https://help.github.com/en/github/building-a-strong-community/configuring-issue-templates-for-your-repository#configuring-the-template-chooser
blank_issues_enabled: false
contact_links:
- name: 📖 Contributing Policy
url: https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md
about: Please read through our contributing policy before opening an issue or pull request
- name: 💬 Discussion Group
url: https://groups.google.com/forum/#!forum/netbox-discuss
about: Join our discussion group for assistance with installation issues and other problems

8
.gitignore vendored
View File

@@ -1,4 +1,5 @@
*.pyc
*.swp
/netbox/netbox/configuration.py
/netbox/netbox/ldap_config.py
/netbox/reports/*
@@ -6,15 +7,14 @@
/netbox/scripts/*
!/netbox/scripts/__init__.py
/netbox/static
.idea
/venv/
/*.sh
!upgrade.sh
fabfile.py
*.swp
gunicorn_config.py
gunicorn.py
netbox.log
netbox.pid
.DS_Store
.vscode
.idea
.coverage
.vscode

View File

@@ -7,6 +7,8 @@ addons:
language: python
python:
- "3.5"
- "3.6"
- "3.7"
install:
- pip install -r requirements.txt
- pip install pycodestyle

View File

@@ -1,5 +1,7 @@
![NetBox](docs/netbox_logo.svg "NetBox logo")
**The [2020 NetBox user survey](https://docs.google.com/forms/d/1OVZuC4kQ-6kJbVf0bDB6vgkL9H96xF6phvYzby23elk/edit) is open!** Your feedback helps guide the project's long-term development.
NetBox is an IP address management (IPAM) and data center infrastructure
management (DCIM) tool. Initially conceived by the network engineering team at
[DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically
@@ -22,21 +24,25 @@ or join us in the #netbox Slack channel on [NetworkToCode](https://networktocode
| **master** | [![Build Status](https://travis-ci.org/netbox-community/netbox.svg?branch=master)](https://travis-ci.com/netbox-community/netbox/) |
| **develop** | [![Build Status](https://travis-ci.org/netbox-community/netbox.svg?branch=develop)](https://travis-ci.com/netbox-community/netbox/) |
## Screenshots
### Screenshots
![Screenshot of main page](docs/media/screenshot1.png "Main page")
---
![Screenshot of rack elevation](docs/media/screenshot2.png "Rack elevation")
---
![Screenshot of prefix hierarchy](docs/media/screenshot3.png "Prefix hierarchy")
# Installation
## Installation
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/netbox-community/netbox/releases)
and run `upgrade.sh`.
# Providing Feedback
## Providing Feedback
Feature requests and bug reports must be submitted as GiHub issues. (Please be
sure to use the [appropriate template](https://github.com/netbox-community/netbox/issues/new/choose).)
@@ -45,6 +51,6 @@ For general discussion, please consider joining our [mailing list](https://group
If you are interested in contributing to the development of NetBox, please read
our [contributing guide](CONTRIBUTING.md) prior to beginning any work.
# Related projects
## Related projects
Please see [our wiki](https://github.com/netbox-community/netbox/wiki/Community-Contributions) for a list of relevant community projects.

View File

@@ -22,6 +22,10 @@ django-filter
# https://github.com/django-mptt/django-mptt
django-mptt
# Context managers for PostgreSQL advisory locks
# https://github.com/Xof/django-pglocks
django-pglocks
# Prometheus metrics library for Django
# https://github.com/korfuri/django-prometheus
django-prometheus
@@ -54,6 +58,10 @@ djangorestframework
# https://github.com/axnsan12/drf-yasg
drf-yasg[validation]
# WSGI HTTP server
# https://gunicorn.org/
gunicorn
# Platform-agnostic template rendering engine
# https://github.com/pallets/jinja
Jinja2
@@ -94,3 +102,7 @@ redis
# SVG image rendering (used for rack elevations)
# https://github.com/mozman/svgwrite
svgwrite
# Python package management tool
# https://pythonwheels.com/
wheel

View File

@@ -7,12 +7,11 @@ Wants=network-online.target
[Service]
Type=simple
User=www-data
Group=www-data
User=netbox
Group=netbox
WorkingDirectory=/opt/netbox
ExecStart=/usr/bin/python3 /opt/netbox/netbox/manage.py rqworker
ExecStart=/opt/netbox/venv/bin/python3 /opt/netbox/netbox/manage.py rqworker
Restart=on-failure
RestartSec=30

View File

@@ -7,12 +7,12 @@ Wants=network-online.target
[Service]
Type=simple
User=www-data
Group=www-data
User=netbox
Group=netbox
PIDFile=/var/tmp/netbox.pid
WorkingDirectory=/opt/netbox
ExecStart=/usr/local/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/netbox --config /opt/netbox/gunicorn.py netbox.wsgi
ExecStart=/opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/netbox --config /opt/netbox/gunicorn.py netbox.wsgi
Restart=on-failure
RestartSec=30

View File

@@ -3,7 +3,7 @@
To improve performance, NetBox supports caching for most object and list views. Caching is implemented using Redis,
and [django-cacheops](https://github.com/Suor/django-cacheops)
Several management commands are avaliable for administrators to manaully invalidate cache entries in extenuating circumstances.
Several management commands are avaliable for administrators to manually invalidate cache entries in extenuating circumstances.
To invalidate a specifc model instance (for example a Device with ID 34):
```

View File

@@ -27,11 +27,17 @@ class MyScript(Script):
var2 = IntegerVar(...)
var3 = ObjectVar(...)
def run(self, data):
def run(self, data, commit):
...
```
The `run()` method is passed a single argument: a dictionary containing all of the variable data passed via the web form. Your script can reference this data during execution.
The `run()` method should accept two arguments:
* `data` - A dictionary containing all of the variable data passed via the web form.
* `commit` - A boolean indicating whether database changes will be committed.
!!! note
The `commit` argument was introduced in NetBox v2.7.8. Backward compatibility is maintained for scripts which accept only the `data` argument, however moving forward scripts should accept both arguments.
Defining variables is optional: You may create a script with only a `run()` method if no user input is needed.
@@ -196,7 +202,7 @@ These variables are presented as a web form to be completed by the user. Once su
```
from django.utils.text import slugify
from dcim.constants import *
from dcim.choices import DeviceStatusChoices, SiteStatusChoices
from dcim.models import Device, DeviceRole, DeviceType, Site
from extras.scripts import *
@@ -222,13 +228,13 @@ class NewBranchScript(Script):
)
)
def run(self, data):
def run(self, data, commit):
# Create the new site
site = Site(
name=data['site_name'],
slug=slugify(data['site_name']),
status=SITE_STATUS_PLANNED
status=SiteStatusChoices.STATUS_PLANNED
)
site.save()
self.log_success("Created new site: {}".format(site))
@@ -240,7 +246,7 @@ class NewBranchScript(Script):
device_type=data['switch_model'],
name='{}-switch{}'.format(site.slug, i),
site=site,
status=DEVICE_STATUS_PLANNED,
status=DeviceStatusChoices.STATUS_PLANNED,
device_role=switch_role
)
switch.save()

View File

@@ -3,7 +3,7 @@
NetBox supports integration with the [NAPALM automation](https://napalm-automation.net/) library. NAPALM allows NetBox to fetch live data from devices and return it to a requester via its REST API.
!!! info
To enable the integration, the NAPALM library must be installed. See [installation steps](../../installation/2-netbox/#napalm-automation-optional) for more information.
To enable the integration, the NAPALM library must be installed. See [installation steps](../../installation/3-netbox/#napalm-automation-optional) for more information.
```
GET /api/dcim/devices/1/napalm/?method=get_environment

View File

@@ -32,7 +32,8 @@ class DeviceIPsReport(Report):
Within each report class, we'll create a number of test methods to execute our report's logic. In DeviceConnectionsReport, for instance, we want to ensure that every live device has a console connection, an out-of-band management connection, and two power connections.
```
from dcim.constants import CONNECTION_STATUS_PLANNED, DEVICE_STATUS_ACTIVE
from dcim.choices import DeviceStatusChoices
from dcim.constants import CONNECTION_STATUS_PLANNED
from dcim.models import ConsolePort, Device, PowerPort
from extras.reports import Report
@@ -43,7 +44,8 @@ class DeviceConnectionsReport(Report):
def test_console_connection(self):
# Check that every console port for every active device has a connection defined.
for console_port in ConsolePort.objects.prefetch_related('device').filter(device__status=DEVICE_STATUS_ACTIVE):
active = DeviceStatusChoices.STATUS_ACTIVE
for console_port in ConsolePort.objects.prefetch_related('device').filter(device__status=active):
if console_port.connected_endpoint is None:
self.log_failure(
console_port.device,
@@ -60,7 +62,7 @@ class DeviceConnectionsReport(Report):
def test_power_connections(self):
# Check that every active device has at least two connected power supplies.
for device in Device.objects.filter(status=DEVICE_STATUS_ACTIVE):
for device in Device.objects.filter(status=DeviceStatusChoices.STATUS_ACTIVE):
connected_ports = 0
for power_port in PowerPort.objects.filter(device=device):
if power_port.connected_endpoint is not None:

View File

@@ -1,61 +1,73 @@
# Webhooks
A webhook defines an HTTP request that is sent to an external application when certain types of objects are created, updated, and/or deleted in NetBox. When a webhook is triggered, a POST request is sent to its configured URL. This request will include a full representation of the object being modified for consumption by the receiver. Webhooks are configured via the admin UI under Extras > Webhooks.
A webhook is a mechanism for conveying to some external system a change that took place in NetBox. For example, you may want to notify a monitoring system whenever a device status is changed in NetBox. This can be done by creating a webhook for the device model in NetBox. When NetBox detects a change to a device, an HTTP request containing the details of the change and who made it be sent to the specified receiver. Webhooks are configured in the admin UI under Extras > Webhooks.
An optional secret key can be configured for each webhook. This will append a `X-Hook-Signature` header to the request, consisting of a HMAC (SHA-512) hex digest of the request body using the secret as the key. This digest can be used by the receiver to authenticate the request's content.
## Configuration
## Requests
* **Name** - A unique name for the webhook. The name is not included with outbound messages.
* **Object type(s)** - The type or types of NetBox object that will trigger the webhook.
* **Enabled** - If unchecked, the webhook will be inactive.
* **Events** - A webhook may trigger on any combination of create, update, and delete events. At least one event type must be selected.
* **HTTP method** - The type of HTTP request to send. Options include GET, POST, PUT, PATCH, and DELETE.
* **URL** - The fuly-qualified URL of the request to be sent. This may specify a destination port number if needed.
* **HTTP content type** - The value of the request's `Content-Type` header. (Defaults to `application/json`)
* **Additional headers** - Any additional headers to include with the request (optional). Add one header per line in the format `Name: Value`. Jinja2 templating is supported for this field (see below).
* **Body template** - The content of the request being sent (optional). Jinja2 templating is supported for this field (see below). If blank, NetBox will populate the request body with a raw dump of the webhook context. (If the HTTP cotent type is set to `application/json`, this will be formatted as a JSON object.)
* **Secret** - A secret string used to prove authenticity of the request (optional). This will append a `X-Hook-Signature` header to the request, consisting of a HMAC (SHA-512) hex digest of the request body using the secret as the key.
* **SSL verification** - Uncheck this option to disable validation of the receiver's SSL certificate. (Disable with caution!)
* **CA file path** - The file path to a particular certificate authority (CA) file to use when validating the receiver's SSL certificate (optional).
The webhook POST request is structured as so (assuming `application/json` as the Content-Type):
## Jinja2 Template Support
[Jinja2 templating](https://jinja.palletsprojects.com/) is supported for the `additional_headers` and `body_template` fields. This enables the user to convey change data in the request headers as well as to craft a customized request body. Request content can be crafted to enable the direct interaction with external systems by ensuring the outgoing message is in a format the receiver expects and understands.
For example, you might create a NetBox webhook to [trigger a Slack message](https://api.slack.com/messaging/webhooks) any time an IP address is created. You can accomplish this using the following configuration:
* Object type: IPAM > IP address
* HTTP method: POST
* URL: <Slack incoming webhook URL>
* HTTP content type: `application/json`
* Body template: `{"text": "IP address {{ data['address'] }} was created by {{ username }}!"}`
### Available Context
The following data is available as context for Jinja2 templates:
* `event` - The type of event which triggered the webhook: created, updated, or deleted.
* `model` - The NetBox model which triggered the change.
* `timestamp` - The time at which the event occurred (in [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) format).
* `username` - The name of the user account associated with the change.
* `request_id` - The unique request ID. This may be used to correlate multiple changes associated with a single request.
* `data` - A serialized representation of the object _after_ the change was made. This is typically equivalent to the model's representation in NetBox's REST API.
### Default Request Body
If no body template is specified, the request body will be populated with a JSON object containing the context data. For example, a newly created site might appear as follows:
```no-highlight
{
"event": "created",
"timestamp": "2019-10-12 12:51:29.746944",
"username": "admin",
"timestamp": "2020-02-25 15:10:26.010582+00:00",
"model": "site",
"request_id": "43d8e212-94c7-4f67-b544-0dcde4fc0f43",
"username": "jstretch",
"request_id": "fdbca812-3142-4783-b364-2e2bd5c16c6a",
"data": {
"id": 19,
"name": "Site 1",
"slug": "site-1",
"status":
"value": "active",
"label": "Active",
"id": 1
},
"region": null,
...
}
}
```
`data` is the serialized representation of the model instance(s) from the event. The same serializers from the NetBox API are used. So an example of the payload for a Site delete event would be:
## Webhook Processing
```no-highlight
{
"event": "deleted",
"timestamp": "2019-10-12 12:55:44.030750",
"username": "johnsmith",
"model": "site",
"request_id": "e9bb83b2-ebe4-4346-b13f-07144b1a00b4",
"data": {
"asn": None,
"comments": "",
"contact_email": "",
"contact_name": "",
"contact_phone": "",
"count_circuits": 0,
"count_devices": 0,
"count_prefixes": 0,
"count_racks": 0,
"count_vlans": 0,
"custom_fields": {},
"facility": "",
"id": 54,
"name": "test",
"physical_address": "",
"region": None,
"shipping_address": "",
"slug": "test",
"tenant": None
}
}
```
When a change is detected, any resulting webhooks are placed into a Redis queue for processing. This allows the user's request to complete without needing to wait for the outgoing webhook(s) to be processed. The webhooks are then extracted from the queue by the `rqworker` process and HTTP requests are sent to their respective destinations. The current webhook queue and any failed webhooks can be inspected in the admin UI under Django RQ > Queues.
A request is considered successful if the response status code is any one of a list of "good" statuses defined in the [requests library](https://github.com/requests/requests/blob/205755834d34a8a6ecf2b0b5b2e9c3e6a7f4e4b6/requests/models.py#L688), otherwise the request is marked as having failed. The user may manually retry a failed request.
## Backend Status
Django-rq includes a status page in the admin site which can be used to view the result of processed webhooks and manually retry any failed webhooks. Access it from http://netbox.local/admin/webhook-backend-status/.
A request is considered successful if the response has a 2XX status code; otherwise, the request is marked as having failed. Failed requests may be retried manually via the admin UI.

71
docs/api/filtering.md Normal file
View File

@@ -0,0 +1,71 @@
# API Filtering
The NetBox API supports robust filtering of results based on the fields of each model.
Generally speaking you are able to filter based on the attributes (fields) present in
the response body. Please note however that certain read-only or metadata fields are not
filterable.
Filtering is achieved by passing HTTP query parameters and the parameter name is the
name of the field you wish to filter on and the value is the field value.
E.g. filtering based on a device's name:
```
/api/dcim/devices/?name=DC-SPINE-1
```
## Multi Value Logic
While you are able to filter based on an arbitrary number of fields, you are also able to
pass multiple values for the same field. In most cases filtering on multiple values is
implemented as a logical OR operation. A notible exception is the `tag` filter which
is a logical AND. Passing multiple values for one field, can be combined with other fields.
For example, filtering for devices with either the name of DC-SPINE-1 _or_ DC-LEAF-4:
```
/api/dcim/devices/?name=DC-SPINE-1&name=DC-LEAF-4
```
Filtering for devices with tag `router` and `customer-a` will return only devices with
_both_ of those tags applied:
```
/api/dcim/devices/?tag=router&tag=customer-a
```
## Lookup Expressions
Certain model fields also support filtering using additonal lookup expressions. This allows
for negation and other context specific filtering.
These lookup expressions can be applied by adding a suffix to the desired field's name.
E.g. `mac_address__n`. In this case, the filter expression is for negation and it is seperated
by two underscores. Below are the lookup expressions that are supported across different field
types.
### Numeric Fields
Numeric based fields (ASN, VLAN ID, etc) support these lookup expressions:
- `n` - not equal (negation)
- `lt` - less than
- `lte` - less than or equal
- `gt` - greater than
- `gte` - greater than or equal
### String Fields
String based (char) fields (Name, Address, etc) support these lookup expressions:
- `n` - not equal (negation)
- `ic` - case insensitive contains
- `nic` - negated case insensitive contains
- `isw` - case insensitive starts with
- `nisw` - negated case insensitive starts with
- `iew` - case insensitive ends with
- `niew` - negated case insensitive ends with
- `ie` - case sensitive exact match
- `nie` - negated case sensitive exact match
### Foreign Keys & Other Fields
Certain other fields, namely foreign key relationships support just the negation
expression: `n`.

View File

@@ -62,6 +62,8 @@ Lists of objects can be filtered using a set of query parameters. For example, t
GET /api/dcim/interfaces/?device_id=123
```
See [filtering](filtering.md) for more details.
# Serialization
The NetBox API employs three types of serializers to represent model data:

View File

@@ -80,11 +80,11 @@ REDIS = {
}
```
!!! note:
!!! note
If you are upgrading from a version prior to v2.7, please note that the Redis connection configuration settings have
changed. Manual modification to bring the `REDIS` section inline with the above specification is necessary
!!! warning:
!!! note
It is highly recommended to keep the webhook and cache databases separate. Using the same database number on the
same Redis instance for both may result in webhook processing data being lost during cache flushing events.
@@ -124,7 +124,7 @@ REDIS = {
}
```
!!! note:
!!! note
It is possible to have only one or the other Redis configurations to use Sentinel functionality. It is possible
for example to have the webhook use sentinel via `HOST`/`PORT` and for caching to use Sentinel via
`SENTINELS`/`SENTINEL_SERVICE`.

View File

@@ -53,6 +53,10 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and
| Task queuing | Redis/django-rq |
| Live device access | NAPALM |
## Supported Python Version
NetBox supports Python 3.5, 3.6, and 3.7 environments currently. Python 3.5 is scheduled to be unsupported in NetBox v2.8.
# Getting Started
See the [installation guide](installation/index.md) for help getting NetBox up and running quickly.

View File

@@ -1,14 +1,13 @@
NetBox requires a PostgreSQL database to store data. This can be hosted locally or on a remote server. (Please note that MySQL is not supported, as NetBox leverages PostgreSQL's built-in [network address types](https://www.postgresql.org/docs/current/static/datatype-net-types.html).)
!!! note
The installation instructions provided here have been tested to work on Ubuntu 18.04 and CentOS 7.5. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
This section entails the installation and configuration of a local PostgreSQL database. If you already have a PostgreSQL database service in place, skip to [the next section](2-redis.md).
!!! warning
NetBox requires PostgreSQL 9.4 or higher.
NetBox requires PostgreSQL 9.4 or higher. Please note that MySQL and other relational databases are **not** supported.
# Installation
The installation instructions provided here have been tested to work on Ubuntu 18.04 and CentOS 7.5. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
**Ubuntu**
## Installation
#### Ubuntu
If a recent enough version of PostgreSQL is not available through your distribution's package manager, you'll need to install it from an official [PostgreSQL repository](https://wiki.postgresql.org/wiki/Apt).
@@ -17,13 +16,13 @@ If a recent enough version of PostgreSQL is not available through your distribut
# apt-get install -y postgresql libpq-dev
```
**CentOS**
#### CentOS
CentOS 7.5 does not ship with a recent enough version of PostgreSQL, so it will need to be installed from an external repository. The instructions below show the installation of PostgreSQL 9.6.
```no-highlight
# yum install https://download.postgresql.org/pub/repos/yum/9.6/redhat/rhel-7-x86_64/pgdg-centos96-9.6-3.noarch.rpm
# yum install postgresql96 postgresql96-server postgresql96-devel
# yum install -y https://download.postgresql.org/pub/repos/yum/9.6/redhat/rhel-7-x86_64/pgdg-centos96-9.6-3.noarch.rpm
# yum install -y postgresql96 postgresql96-server postgresql96-devel
# /usr/pgsql-9.6/bin/postgresql96-setup initdb
```
@@ -41,7 +40,7 @@ Then, start the service and enable it to run at boot:
# systemctl enable postgresql-9.6
```
# Database Creation
## 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.
@@ -62,6 +61,8 @@ GRANT
postgres=# \q
```
## Verify Service Status
You can verify that authentication works issuing the following command and providing the configured password. (Replace `localhost` with your database server if using a remote database.)
```no-highlight

View File

@@ -0,0 +1,27 @@
[Redis](https://redis.io/) is an in-memory key-value store which NetBox employs for caching and queuing. This section entails the installation and configuration of a local Redis instance. If you already have a Redis service in place, skip to [the next section](3-netbox.md).
#### Ubuntu
```no-highlight
# apt-get install -y redis-server
```
#### CentOS
```no-highlight
# yum install -y epel-release
# yum install -y redis
# systemctl start redis
# systemctl enable redis
```
You may wish to modify the Redis configuration at `/etc/redis.conf` or `/etc/redis/redis.conf`, however in most cases the default configuration is sufficient.
## Verify Service Status
Use the `redis-cli` utility to ensure the Redis service is functional:
```no-highlight
$ redis-cli ping
PONG
```

View File

@@ -1,25 +1,25 @@
# Installation
This section of the documentation discusses installing and configuring the NetBox application. Begin by installing all system packages required by NetBox and its dependencies:
**Ubuntu**
## Install System Packages
#### Ubuntu
```no-highlight
# apt-get install -y python3 python3-pip python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev libpq-dev libssl-dev redis-server zlib1g-dev
# apt-get install -y python3 python3-pip python3-venv python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev libpq-dev libssl-dev zlib1g-dev
```
**CentOS**
#### CentOS
```no-highlight
# yum install -y epel-release
# yum install -y gcc python36 python36-devel python36-setuptools libxml2-devel libxslt-devel libffi-devel openssl-devel redhat-rpm-config redis
# yum install -y gcc python36 python36-devel python36-setuptools libxml2-devel libxslt-devel libffi-devel openssl-devel redhat-rpm-config
# easy_install-3.6 pip
# ln -s /usr/bin/python3.6 /usr/bin/python3
```
## Download NetBox
You may opt to install NetBox either from a numbered release or by cloning the master branch of its repository on GitHub.
## Option A: Download a Release
### Option A: Download a Release
Download the [latest stable release](https://github.com/netbox-community/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`.
@@ -31,7 +31,7 @@ Download the [latest stable release](https://github.com/netbox-community/netbox/
# cd /opt/netbox/
```
## Option B: Clone the Git Repository
### Option B: Clone the Git Repository
Create the base directory for the NetBox installation. For this guide, we'll use `/opt/netbox`.
@@ -41,13 +41,13 @@ Create the base directory for the NetBox installation. For this guide, we'll use
If `git` is not already installed, install it:
**Ubuntu**
#### Ubuntu
```no-highlight
# apt-get install -y git
```
**CentOS**
#### CentOS
```no-highlight
# yum install -y git
@@ -66,45 +66,56 @@ Resolving deltas: 100% (1495/1495), done.
Checking connectivity... done.
```
!!! warning
Ensure that the media directory (`/opt/netbox/netbox/media/` in this example) and all its subdirectories are writable by the user account as which NetBox runs. If the NetBox process does not have permission to write to this directory, attempts to upload files (e.g. image attachments) will fail. (The appropriate user account will vary by platform.)
## Create the NetBox User
`# chown -R netbox:netbox /opt/netbox/netbox/media/`
# Install Python Packages
Install the required Python packages using pip. (If you encounter any compilation errors during this step, ensure that you've installed all of the system dependencies listed above.)
```no-highlight
# pip3 install -r requirements.txt
```
Create a system user account named `netbox`. We'll configure the WSGI and HTTP services to run under this account. We'll also assign this user ownership of the media directory. This ensures that NetBox will be able to save local files.
!!! note
If you encounter errors while installing the required packages, check that you're running a recent version of pip (v9.0.1 or higher) with the command `pip3 -V`.
CentOS users may need to create the `netbox` group first.
## NAPALM Automation (Optional)
NetBox supports integration with the [NAPALM automation](https://napalm-automation.net/) library. NAPALM allows NetBox to fetch live data from devices and return it to a requester via its REST API. Installation of NAPALM is optional. To enable it, install the `napalm` package using pip or pip3:
```no-highlight
# pip3 install napalm
```
# adduser --system --group netbox
# chown --recursive netbox /opt/netbox/netbox/media/
```
## Remote File Storage (Optional)
## Set Up Python Environment
We'll use a Python [virtual environment](https://docs.python.org/3.6/tutorial/venv.html) to ensure NetBox's required packages don't conflict with anything in the base system. This will create a directory named `venv` in our NetBox root.
```no-highlight
# python3 -m venv /opt/netbox/venv
```
Next, activate the virtual environment and install the required Python packages. You should see your console prompt change to indicate the active environment. (Activating the virtual environment updates your command shell to use the local copy of Python that we just installed for NetBox instead of the system's Python interpreter.)
```no-highlight
# source venv/bin/activate
(venv) # pip3 install -r requirements.txt
```
#### NAPALM Automation (Optional)
NetBox supports integration with the [NAPALM automation](https://napalm-automation.net/) library. NAPALM allows NetBox to fetch live data from devices and return it to a requester via its REST API. Installation of NAPALM is optional. To enable it, install the `napalm` package:
```no-highlight
(venv) # pip3 install napalm
```
#### Remote File Storage (Optional)
By default, NetBox will use the local filesystem to storage uploaded files. To use a remote filesystem, install the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) library and configure your [desired backend](../../configuration/optional-settings/#storage_backend) in `configuration.py`.
```no-highlight
# pip3 install django-storages
(venv) # pip3 install django-storages
```
# Configuration
## 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
(venv) # cd netbox/netbox/
(venv) # cp configuration.example.py configuration.py
```
Open `configuration.py` with your preferred editor and set the following variables:
@@ -114,7 +125,7 @@ Open `configuration.py` with your preferred editor and set the following variabl
* `REDIS`
* `SECRET_KEY`
## ALLOWED_HOSTS
### ALLOWED_HOSTS
This is a list of the valid hostnames by which this server can be reached. You must specify at least one name or IP address.
@@ -124,7 +135,7 @@ Example:
ALLOWED_HOSTS = ['netbox.example.com', '192.0.2.123']
```
## DATABASE
### DATABASE
This parameter holds the database configuration details. You must define the username and password used when you configured PostgreSQL. If the service is running on a remote host, replace `localhost` with its address. See the [configuration documentation](../../configuration/required-settings/#database) for more detail on individual parameters.
@@ -141,7 +152,7 @@ DATABASE = {
}
```
## REDIS
### REDIS
Redis is a in-memory key-value store required as part of the NetBox installation. It is used for features such as webhooks and caching. Redis typically requires minimal configuration; the values below should suffice for most installations. See the [configuration documentation](../../configuration/required-settings/#redis) for more detail on individual parameters.
@@ -166,7 +177,7 @@ REDIS = {
}
```
## SECRET_KEY
### SECRET_KEY
Generate a random secret key of at least 50 alphanumeric characters. This key must be unique to this installation and must not be shared outside the local system.
@@ -175,13 +186,13 @@ You may use the script located at `netbox/generate_secret_key.py` to generate a
!!! note
In the case of a highly available installation with multiple web servers, `SECRET_KEY` must be identical among all servers in order to maintain a persistent user session state.
# Run Database Migrations
## Run Database Migrations
Before NetBox can run, we need to install the database schema. This is done by running `python3 manage.py migrate` from the `netbox` directory (`/opt/netbox/netbox/` in our example):
```no-highlight
# cd /opt/netbox/netbox/
# python3 manage.py migrate
(venv) # cd /opt/netbox/netbox/
(venv) # python3 manage.py migrate
Operations to perform:
Apply all migrations: dcim, sessions, admin, ipam, utilities, auth, circuits, contenttypes, extras, secrets, users
Running migrations:
@@ -194,12 +205,12 @@ Running migrations:
If this step results in a PostgreSQL authentication error, ensure that the username and password created in the database match what has been specified in `configuration.py`
# Create a Super User
## Create a Super User
NetBox does not come with any predefined user accounts. You'll need to create a super user to be able to log into NetBox:
```no-highlight
# python3 manage.py createsuperuser
(venv) # python3 manage.py createsuperuser
Username: admin
Email address: admin@example.com
Password:
@@ -207,20 +218,20 @@ Password (again):
Superuser created successfully.
```
# Collect Static Files
## Collect Static Files
```no-highlight
# python3 manage.py collectstatic --no-input
(venv) # python3 manage.py collectstatic --no-input
959 static files copied to '/opt/netbox/netbox/static'.
```
# Test the Application
## Test the Application
At this point, NetBox should be able to run. We can verify this by starting a development instance:
```no-highlight
# python3 manage.py runserver 0.0.0.0:8000 --insecure
(venv) # python3 manage.py runserver 0.0.0.0:8000 --insecure
Performing system checks...
System check identified no issues (0 silenced).

View File

@@ -3,9 +3,9 @@ We'll set up a simple WSGI front end using [gunicorn](http://gunicorn.org/) for
!!! info
For the sake of brevity, only Ubuntu 18.04 instructions are provided here, but this sort of web server and WSGI configuration is not unique to NetBox. Please consult your distribution's documentation for assistance if needed.
# Web Server Installation
## HTTP Daemon Installation
## Option A: nginx
### Option A: nginx
The following will serve as a minimal nginx configuration. Be sure to modify your server name and installation path appropriately.
@@ -52,7 +52,7 @@ Restart the nginx service to use the new configuration.
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-16-04).
## Option B: Apache
### Option B: Apache
```no-highlight
# apt-get install -y apache2 libapache2-mod-wsgi-py3
@@ -99,15 +99,12 @@ Save the contents of the above example in `/etc/apache2/sites-available/netbox.c
To enable SSL, consider this guide on [securing Apache with Let's Encrypt](https://www.digitalocean.com/community/tutorials/how-to-secure-apache-with-let-s-encrypt-on-ubuntu-16-04).
# gunicorn Installation
!!! note
Certain components of NetBox (such as the display of rack elevation diagrams) rely on the use of embedded objects. Ensure that your HTTP server configuration does not override the `X-Frame-Options` response header set by NetBox.
Install gunicorn:
## gunicorn Configuration
```no-highlight
# pip3 install gunicorn
```
Copy `/opt/netbox/contrib/gunicorn.py` to `/opt/netbox/gunicorn.py`. We make a copy of this file to ensure that any changes to it do not get overwritten by a future upgrade.
Copy `/opt/netbox/contrib/gunicorn.py` to `/opt/netbox/gunicorn.py`. (We make a copy of this file to ensure that any changes to it do not get overwritten by a future upgrade.)
```no-highlight
# cd /opt/netbox
@@ -116,7 +113,7 @@ Copy `/opt/netbox/contrib/gunicorn.py` to `/opt/netbox/gunicorn.py`. We make a c
You may wish to edit this file to change the bound IP address or port number, or to make performance-related adjustments.
# systemd configuration
## systemd Configuration
We'll use systemd to control the daemonization of NetBox services. First, copy `contrib/netbox.service` and `contrib/netbox-rq.service` to the `/etc/systemd/system/` directory:
@@ -124,17 +121,12 @@ We'll use systemd to control the daemonization of NetBox services. First, copy `
# cp contrib/*.service /etc/systemd/system/
```
!!! note
These service files assume that gunicorn is installed at `/usr/local/bin/gunicorn`. If the output of `which gunicorn` indicates a different path, you'll need to correct the `ExecStart` path in both files.
Then, start the `netbox` and `netbox-rq` services and enable them to initiate at boot time:
```no-highlight
# systemctl daemon-reload
# systemctl start netbox.service
# systemctl start netbox-rq.service
# systemctl enable netbox.service
# systemctl enable netbox-rq.service
# systemctl start netbox netbox-rq
# systemctl enable netbox netbox-rq
```
You can use the command `systemctl status netbox` to verify that the WSGI service is running:
@@ -154,7 +146,20 @@ You can use the command `systemctl status netbox` to verify that the WSGI servic
...
```
At this point, you should be able to connect to the HTTP service at the server name or IP address you provided. If you are unable to connect, check that the nginx service is running and properly configured. If you receive a 502 (bad gateway) error, this indicates that gunicorn is misconfigured or not running.
At this point, you should be able to connect to the HTTP service at the server name or IP address you provided.
!!! info
Please keep in mind that the configurations provided here are bare minimums required to get NetBox up and running. You may want to make adjustments to better suit your production environment.
## Troubleshooting
If you are unable to connect to the HTTP server, check that:
* Nginx/Apache is running and configured to listen on the correct port.
* Access is not being blocked by a firewall. (Try connecting locally from the server itself.)
If you are able to connect but receive a 502 (bad gateway) error, check the following:
* The NetBox system process (gunicorn) is running: `systemctl status netbox`
* nginx/Apache is configured to connect to the port on which gunicorn is listening (default is 8001).
* SELinux is not preventing the reverse proxy connection. You may need to allow HTTP network connections with the command `setsebool -P httpd_can_network_connect 1`

View File

@@ -1,8 +1,8 @@
This guide explains how to implement LDAP authentication using an external server. User authentication will fall back to built-in Django users in the event of a failure.
# Requirements
## Install Requirements
## Install openldap-devel
#### Install openldap-devel
On Ubuntu:
@@ -16,17 +16,17 @@ On CentOS:
sudo yum install -y openldap-devel
```
## Install django-auth-ldap
#### Install django-auth-ldap
```no-highlight
pip3 install django-auth-ldap
```
# Configuration
## Configuration
Create a file in the same directory as `configuration.py` (typically `netbox/netbox/`) named `ldap_config.py`. Define all of the parameters required below in `ldap_config.py`. Complete documentation of all `django-auth-ldap` configuration options is included in the project's [official documentation](http://django-auth-ldap.readthedocs.io/).
## General Server Configuration
### General Server Configuration
!!! info
When using Windows Server 2012 you may need to specify a port on `AUTH_LDAP_SERVER_URI`. Use `3269` for secure, or `3268` for non-secure.
@@ -54,7 +54,7 @@ LDAP_IGNORE_CERT_ERRORS = True
STARTTLS can be configured by setting `AUTH_LDAP_START_TLS = True` and using the `ldap://` URI scheme.
## User Authentication
### User Authentication
!!! info
When using Windows Server 2012, `AUTH_LDAP_USER_DN_TEMPLATE` should be set to None.
@@ -79,7 +79,7 @@ AUTH_LDAP_USER_ATTR_MAP = {
}
```
# User Groups for Permissions
## User Groups for Permissions
!!! info
When using Microsoft Active Directory, support for nested groups can be activated by using `NestedGroupOfNamesType()` instead of `GroupOfNamesType()` for `AUTH_LDAP_GROUP_TYPE`. You will also need to modify the import line to use `NestedGroupOfNamesType` instead of `GroupOfNamesType` .
@@ -121,7 +121,7 @@ AUTH_LDAP_CACHE_TIMEOUT = 3600
!!! warning
Authentication will fail if the groups (the distinguished names) do not exist in the LDAP directory.
# Troubleshooting LDAP
## Troubleshooting LDAP
`supervisorctl restart netbox` restarts the Netbox service, and initiates any changes made to `ldap_config.py`. If there are syntax errors present, the NetBox process will not spawn an instance, and errors should be logged to `/var/log/supervisor/`.

View File

@@ -3,14 +3,13 @@
The following sections detail how to set up a new instance of NetBox:
1. [PostgreSQL database](1-postgresql.md)
2. [NetBox components](2-netbox.md)
3. [HTTP daemon](3-http-daemon.md)
4. [LDAP authentication](4-ldap.md) (optional)
1. [Redis](2-redis.md)
3. [NetBox components](3-netbox.md)
4. [HTTP daemon](4-http-daemon.md)
5. [LDAP authentication](5-ldap.md) (optional)
# Upgrading
If you are upgrading from an existing installation, please consult the [upgrading guide](upgrading.md).
NetBox v2.5 and later requires Python 3.5 or higher. Please see the instructions for [migrating to Python 3](migrating-to-python3.md) if you are still using Python 2.
Netbox v2.5.9 and later moved to using systemd instead of supervisord. Please see the instructions for [migrating to systemd](migrating-to-systemd.md) if you are still using supervisord.

View File

@@ -1,38 +0,0 @@
# Migration
!!! warning
As of version 2.5, NetBox no longer supports Python 2. Python 3 is required to run any 2.5 release or later.
## Ubuntu
Remove the Python2 version of gunicorn:
```no-highlight
# pip uninstall -y gunicorn
```
Install Python3 and pip3, Python's package management tool:
```no-highlight
# apt-get update
# apt-get install -y python3 python3-dev python3-setuptools
# easy_install3 pip
```
Install the Python3 packages required by NetBox:
```no-highlight
# pip3 install -r requirements.txt
```
Replace gunicorn with the Python3 version:
```no-highlight
# pip3 install gunicorn
```
If using LDAP authentication, install the `django-auth-ldap` package:
```no-highlight
# pip3 install django-auth-ldap
```

View File

@@ -1,16 +1,17 @@
# Migration
Migration is not required, as supervisord will still continue to function.
This document contains instructions for migrating from a legacy NetBox deployment using [supervisor](http://supervisord.org/) to a systemd-based approach.
## Ubuntu
### Remove supervisord:
### Uninstall supervisord:
```no-highlight
# apt-get remove -y supervisord
```
### systemd configuration:
### Configure systemd:
!!! note
These instructions assume the presence of a Python virtual environment at `/opt/netbox/venv`. If you have not created this environment, please refer to the [installation instructions](3-netbox.md#set-up-python-environment) for direction.
We'll use systemd to control the daemonization of NetBox services. First, copy `contrib/netbox.service` and `contrib/netbox-rq.service` to the `/etc/systemd/system/` directory:
@@ -19,19 +20,14 @@ We'll use systemd to control the daemonization of NetBox services. First, copy `
```
!!! note
These service files assume that gunicorn is installed at `/usr/local/bin/gunicorn`. If the output of `which gunicorn` indicates a different path, you'll need to correct the `ExecStart` path in both files.
!!! note
You may need to modify the user that the systemd service runs as. Please verify the user for httpd on your specific release and edit both files to match your httpd service under user and group. The username could be "nobody", "nginx", "apache", "www-data" or any number of other usernames.
You may need to modify the user that the systemd service runs as. Please verify the user for httpd on your specific release and edit both files to match your httpd service under user and group. The username could be "nobody", "nginx", "apache", "www-data", or something else.
Then, start the `netbox` and `netbox-rq` services and enable them to initiate at boot time:
```no-highlight
# systemctl daemon-reload
# systemctl start netbox.service
# systemctl start netbox-rq.service
# systemctl enable netbox.service
# systemctl enable netbox-rq.service
# systemctl start netbox netbox-rq
# systemctl enable netbox netbox-rq
```
You can use the command `systemctl status netbox` to verify that the WSGI service is running:
@@ -51,7 +47,7 @@ You can use the command `systemctl status netbox` to verify that the WSGI servic
...
```
At this point, you should be able to connect to the HTTP service at the server name or IP address you provided. If you are unable to connect, check that the nginx service is running and properly configured. If you receive a 502 (bad gateway) error, this indicates that gunicorn is misconfigured or not running.
At this point, you should be able to connect to the HTTP service at the server name or IP address you provided. If you are unable to connect, check that the nginx service is running and properly configured. If you receive a 502 (bad gateway) error, this indicates that gunicorn is misconfigured or not running. Issue the command `journalctl -xe` to see why the services were unable to start.
!!! info
Please keep in mind that the configurations provided here are bare minimums required to get NetBox up and running. You may want to make adjustments to better suit your production environment.

View File

@@ -1,12 +1,12 @@
# Review the Release Notes
## Review the Release Notes
Prior to upgrading your NetBox instance, be sure to carefully review all [release notes](../../release-notes/) that have been published since your current version was released. Although the upgrade process typically does not involve additional work, certain releases may introduce breaking or backward-incompatible changes. These are called out in the release notes under the version in which the change went into effect.
# Install the Latest Code
## Install the Latest Code
As with the initial installation, you can upgrade NetBox by either downloading the latest release package or by cloning the `master` branch of the git repository.
## Option A: Download a Release
### Option A: Download a Release
Download the [latest stable release](https://github.com/netbox-community/netbox/releases) from GitHub as a tarball or ZIP archive. Extract it to your desired path. In this example, we'll use `/opt/netbox`.
@@ -34,7 +34,7 @@ Be sure to replicate your uploaded media as well. (The exact action necessary wi
Also make sure to copy over any reports that you've made. Note that if you made them in a separate directory (`/opt/netbox-reports` for example), then you will not need to copy them - the config file that you copied earlier will point to the correct location.
```no-highlight
# cp -r /opt/netbox-X.Y.X/netbox/reports /opt/netbox/netbox/reports/
# cp -r /opt/netbox-X.Y.Z/netbox/reports /opt/netbox/netbox/reports/
```
If you followed the original installation guide to set up gunicorn, be sure to copy its configuration as well:
@@ -49,7 +49,7 @@ Copy the LDAP configuration if using LDAP:
# cp netbox-X.Y.Z/netbox/netbox/ldap_config.py netbox/netbox/netbox/ldap_config.py
```
## Option B: Clone the Git Repository (latest master release)
### 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:
@@ -60,9 +60,9 @@ This guide assumes that NetBox is installed at `/opt/netbox`. Pull down the most
# git status
```
# Run the Upgrade Script
## Run the Upgrade Script
Once the new code is in place, run the upgrade script (which may need to be run as root depending on how your environment is configured).
Once the new code is in place, run the upgrade script:
```no-highlight
# ./upgrade.sh
@@ -70,9 +70,13 @@ Once the new code is in place, run the upgrade script (which may need to be run
This script:
* Installs or upgrades any new required Python packages
* Destroys and rebuilds the Python virtual environment
* Installs all required Python packages
* Applies any database migrations that were included in the release
* Collects all static files to be served by the HTTP service
* Deletes stale content types from the database
* Deletes all expired user sessions from the database
* Clears all cached data to prevent conflicts with the new release
!!! note
It's possible that the upgrade script will display a notice warning of unreflected database migrations:
@@ -82,14 +86,16 @@ This script:
This may occur due to semantic differences in environment, and can be safely ignored. Never attempt to create new migrations unless you are intentionally modifying the database schema.
# Restart the WSGI Service
## Restart the NetBox Services
Finally, restart the WSGI services to run the new code. If you followed this guide for the initial installation, this is done using `systemctl:
!!! warning
If you are upgrading from an installation that does not use a Python virtual environment, you'll need to update the systemd service files to reference the new Python and gunicorn executables before restarting the services. These are located in `/opt/netbox/venv/bin/`. See the example service files in `/opt/netbox/contrib/` for reference.
Finally, restart the gunicorn and RQ services:
```no-highlight
# sudo systemctl restart netbox
# sudo systemctl restart netbox-rq
# sudo systemctl restart netbox netbox-rq
```
!!! note
It's possible you are still using supervisord instead of the linux native systemd. If you are still using supervisord you can restart the services by either restarting supervisord or by using supervisorctl to restart netbox.
It's possible you are still using supervisord instead of systemd. If so, please see the instructions for [migrating to systemd](migrating-to-systemd.md).

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 339 KiB

View File

@@ -1,3 +1,98 @@
# v2.7.9 (2020-03-06)
**Note:** This release will deploy a Python virtual environment on upgrade in the `venv/` directory. This will require modifying the paths to your Python and gunicorn executables in the systemd service files. For more detail, please see the [upgrade instructions](https://netbox.readthedocs.io/en/stable/installation/upgrading/).
## Enhancements
* [#3949](https://github.com/netbox-community/netbox/issues/3949) - Revised the installation docs and upgrade script to employ a Python virtual environment
* [#4062](https://github.com/netbox-community/netbox/issues/4062) - Enumerate ChoiceField type and value in API
* [#4119](https://github.com/netbox-community/netbox/issues/4119) - Extend upgrade script to clear expired user sessions
* [#4121](https://github.com/netbox-community/netbox/issues/4121) - Add dynamic lookup expressions for all filters
* [#4218](https://github.com/netbox-community/netbox/issues/4218) - Allow negative voltage for DC power feeds
* [#4281](https://github.com/netbox-community/netbox/issues/4281) - Allow filtering device component list views by type
* [#4284](https://github.com/netbox-community/netbox/issues/4284) - Add MRJ21 port and cable types
* [#4290](https://github.com/netbox-community/netbox/issues/4290) - Include device name in tooltip on rack elevations
* [#4305](https://github.com/netbox-community/netbox/issues/4305) - Add 10-inch option for rack width
## Bug Fixes
* [#4274](https://github.com/netbox-community/netbox/issues/4274) - Fix incorrect schema definition of `int` type choicefields
* [#4277](https://github.com/netbox-community/netbox/issues/4277) - Fix filtering of clusters by tenant
* [#4282](https://github.com/netbox-community/netbox/issues/4282) - Fix label on export button for device types
* [#4285](https://github.com/netbox-community/netbox/issues/4285) - Include A/Z termination sites in provider circuits table
* [#4295](https://github.com/netbox-community/netbox/issues/4295) - Fix assignment of parent LAG during interface bulk edit
* [#4298](https://github.com/netbox-community/netbox/issues/4298) - Fix bulk creation of objects with custom fields via REST API
* [#4300](https://github.com/netbox-community/netbox/issues/4300) - Pass "commit" argument when executing scripts via REST API
* [#4301](https://github.com/netbox-community/netbox/issues/4301) - Fix exception when deleting device type with components
* [#4306](https://github.com/netbox-community/netbox/issues/4306) - Fix toggling of device images for all racks in elevations view
---
# v2.7.8 (2020-02-25)
## Enhancements
* [#3145](https://github.com/netbox-community/netbox/issues/3145) - Add a "decommissioning" cable status
* [#4173](https://github.com/netbox-community/netbox/issues/4173) - Return graceful error message when webhook queuing fails
* [#4227](https://github.com/netbox-community/netbox/issues/4227) - Omit internal fields from the change log data
* [#4237](https://github.com/netbox-community/netbox/issues/4237) - Support Jinja2 templating for webhook payload and headers
* [#4262](https://github.com/netbox-community/netbox/issues/4262) - Extend custom scripts to pass the `commit` value via `run()`
* [#4267](https://github.com/netbox-community/netbox/issues/4267) - Denote rack role on rack elevations list
## Bug Fixes
* [#4221](https://github.com/netbox-community/netbox/issues/4221) - Fix exception when deleting a device with interface connections when an interfaces webhook is defined
* [#4222](https://github.com/netbox-community/netbox/issues/4222) - Escape double quotes on encapsulated values during CSV export
* [#4224](https://github.com/netbox-community/netbox/issues/4224) - Fix display of rear device image if front image is not defined
* [#4228](https://github.com/netbox-community/netbox/issues/4228) - Improve fit of device images in rack elevations
* [#4230](https://github.com/netbox-community/netbox/issues/4230) - Fix rack units filtering on elevation endpoint
* [#4232](https://github.com/netbox-community/netbox/issues/4232) - Enforce consistent background striping in rack elevations
* [#4235](https://github.com/netbox-community/netbox/issues/4235) - Fix API representation of `content_type` for export templates
* [#4239](https://github.com/netbox-community/netbox/issues/4239) - Fix exception when selecting all filtered objects during bulk edit
* [#4240](https://github.com/netbox-community/netbox/issues/4240) - Fix exception when filtering foreign keys by NULL
* [#4241](https://github.com/netbox-community/netbox/issues/4241) - Correct IP address hyperlinks on interface view
* [#4246](https://github.com/netbox-community/netbox/issues/4246) - Fix duplication of field attributes when multiple IPNetworkVars are present in a script
* [#4252](https://github.com/netbox-community/netbox/issues/4252) - Fix power port assignment for power outlet templates created via REST API
* [#4272](https://github.com/netbox-community/netbox/issues/4272) - Interface type should be required by API serializer
---
# v2.7.7 (2020-02-20)
**Note:** This release fixes a bug affecting the natural ordering of interfaces. If any interfaces appear unordered in
NetBox, run the following management command to recalculate their naturalized values after upgrading:
```
python3 manage.py renaturalize dcim.Interface
```
## Enhancements
* [#1529](https://github.com/netbox-community/netbox/issues/1529) - Enable display of device images in rack elevations
* [#2511](https://github.com/netbox-community/netbox/issues/2511) - Compare object change to the previous change
* [#3810](https://github.com/netbox-community/netbox/issues/3810) - Preserve slug value when editing existing objects
* [#3840](https://github.com/netbox-community/netbox/issues/3840) - Enhance search function when selecting VLANs for interface assignment
* [#4170](https://github.com/netbox-community/netbox/issues/4170) - Improve color contrast in rack elevation drawings
* [#4206](https://github.com/netbox-community/netbox/issues/4206) - Add RJ-11 console port type
* [#4209](https://github.com/netbox-community/netbox/issues/4209) - Enable filtering interfaces list view by enabled
## Bug Fixes
* [#2519](https://github.com/netbox-community/netbox/issues/2519) - Avoid race condition when provisioning "next available" IPs/prefixes via the API
* [#3967](https://github.com/netbox-community/netbox/issues/3967) - Fix missing migration for interface templates of type "other"
* [#4168](https://github.com/netbox-community/netbox/issues/4168) - Role is not required when creating a virtual machine
* [#4175](https://github.com/netbox-community/netbox/issues/4175) - Fix potential exception when bulk editing objects from a filtered list
* [#4179](https://github.com/netbox-community/netbox/issues/4179) - Site is required when creating a rack group or power panel
* [#4183](https://github.com/netbox-community/netbox/issues/4183) - Fix representation of NaturalOrderingField values in change log
* [#4194](https://github.com/netbox-community/netbox/issues/4194) - Role field should not be required when searching/filtering secrets
* [#4196](https://github.com/netbox-community/netbox/issues/4196) - Fix exception when viewing LLDP neighbors page
* [#4202](https://github.com/netbox-community/netbox/issues/4202) - Prevent reassignment to master device when bulk editing VC member interfaces
* [#4204](https://github.com/netbox-community/netbox/issues/4204) - Fix assignment of mask length when bulk editing prefixes
* [#4211](https://github.com/netbox-community/netbox/issues/4211) - Include trailing text when naturalizing interface names
* [#4213](https://github.com/netbox-community/netbox/issues/4213) - Restore display of tags and custom fields on power feed view
---
# v2.7.6 (2020-02-13)
## Bug Fixes

View File

@@ -1,17 +1,22 @@
site_name: NetBox
theme: readthedocs
site_name: NetBox Documentation
site_url: https://netbox.readthedocs.io/
repo_url: https://github.com/netbox-community/netbox
theme:
name: readthedocs
navigation_depth: 3
markdown_extensions:
- admonition:
pages:
nav:
- Introduction: 'index.md'
- Installation:
- Installing NetBox: 'installation/index.md'
- 1. PostgreSQL: 'installation/1-postgresql.md'
- 2. NetBox: 'installation/2-netbox.md'
- 3. HTTP Daemon: 'installation/3-http-daemon.md'
- 4. LDAP (Optional): 'installation/4-ldap.md'
- 2. Redis: 'installation/2-redis.md'
- 3. NetBox: 'installation/3-netbox.md'
- 4. HTTP Daemon: 'installation/4-http-daemon.md'
- 5. LDAP (Optional): 'installation/5-ldap.md'
- Upgrading NetBox: 'installation/upgrading.md'
- Migrating to Python3: 'installation/migrating-to-python3.md'
- Migrating to systemd: 'installation/migrating-to-systemd.md'
- Configuration:
- Configuring NetBox: 'configuration/index.md'
@@ -50,6 +55,7 @@ pages:
- Authentication: 'api/authentication.md'
- Working with Secrets: 'api/working-with-secrets.md'
- Examples: 'api/examples.md'
- Filtering: 'api/filtering.md'
- Development:
- Introduction: 'development/index.md'
- Style Guide: 'development/style-guide.md'
@@ -76,6 +82,3 @@ pages:
- Version 1.2: 'release-notes/version-1.2.md'
- Version 1.1: 'release-notes/version-1.1.md'
- Version 1.0: 'release-notes/version-1.0.md'
markdown_extensions:
- admonition:

View File

@@ -4,7 +4,9 @@ from django.db.models import Q
from dcim.models import Region, Site
from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
from tenancy.filters import TenancyFilterSet
from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter
from utilities.filters import (
BaseFilterSet, NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter
)
from .choices import *
from .models import Circuit, CircuitTermination, CircuitType, Provider
@@ -16,7 +18,7 @@ __all__ = (
)
class ProviderFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet):
class ProviderFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
id__in = NumericInFilter(
field_name='id',
lookup_expr='in'
@@ -27,12 +29,14 @@ class ProviderFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet):
)
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='circuits__terminations__site__region__in',
field_name='circuits__terminations__site__region',
lookup_expr='in',
label='Region (ID)',
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='circuits__terminations__site__region__in',
field_name='circuits__terminations__site__region',
lookup_expr='in',
to_field_name='slug',
label='Region (slug)',
)
@@ -65,14 +69,14 @@ class ProviderFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet):
)
class CircuitTypeFilterSet(NameSlugSearchFilterSet):
class CircuitTypeFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
class Meta:
model = CircuitType
fields = ['id', 'name', 'slug']
class CircuitFilterSet(CustomFieldFilterSet, TenancyFilterSet, CreatedUpdatedFilterSet):
class CircuitFilterSet(BaseFilterSet, CustomFieldFilterSet, TenancyFilterSet, CreatedUpdatedFilterSet):
id__in = NumericInFilter(
field_name='id',
lookup_expr='in'
@@ -118,12 +122,14 @@ class CircuitFilterSet(CustomFieldFilterSet, TenancyFilterSet, CreatedUpdatedFil
)
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='terminations__site__region__in',
field_name='terminations__site__region',
lookup_expr='in',
label='Region (ID)',
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='terminations__site__region__in',
field_name='terminations__site__region',
lookup_expr='in',
to_field_name='slug',
label='Region (slug)',
)
@@ -146,7 +152,7 @@ class CircuitFilterSet(CustomFieldFilterSet, TenancyFilterSet, CreatedUpdatedFil
).distinct()
class CircuitTerminationFilterSet(django_filters.FilterSet):
class CircuitTerminationFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',

View File

@@ -10,6 +10,7 @@ from extras.models import CustomFieldModel, ObjectChange, TaggedItem
from utilities.models import ChangeLoggedModel
from utilities.utils import serialize_object
from .choices import *
from .querysets import CircuitQuerySet
__all__ = (
@@ -184,6 +185,7 @@ class Circuit(ChangeLoggedModel, CustomFieldModel):
object_id_field='obj_id'
)
objects = CircuitQuerySet.as_manager()
tags = TaggableManager(through=TaggedItem)
csv_headers = [

View File

@@ -0,0 +1,15 @@
from django.db.models import OuterRef, QuerySet, Subquery
class CircuitQuerySet(QuerySet):
def annotate_sites(self):
"""
Annotate the A and Z termination site names for ordering.
"""
from circuits.models import CircuitTermination
_terminations = CircuitTermination.objects.filter(circuit=OuterRef('pk'))
return self.annotate(
a_side=Subquery(_terminations.filter(term_side='A').values('site__name')[:1]),
z_side=Subquery(_terminations.filter(term_side='Z').values('site__name')[:1]),
)

View File

@@ -6,7 +6,7 @@ from utilities.tables import BaseTable, ToggleColumn
from .models import Circuit, CircuitType, Provider
CIRCUITTYPE_ACTIONS = """
<a href="{% url 'circuits:circuittype_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
<a href="{% url 'circuits:circuittype_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
<i class="fa fa-history"></i>
</a>
{% if perms.circuit.change_circuittype %}

View File

@@ -4,6 +4,7 @@ from circuits.choices import *
from circuits.filters import *
from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
from dcim.models import Region, Site
from tenancy.models import Tenant, TenantGroup
class ProviderTestCase(TestCase):
@@ -138,6 +139,20 @@ class CircuitTestCase(TestCase):
)
Site.objects.bulk_create(sites)
tenant_groups = (
TenantGroup(name='Tenant group 1', slug='tenant-group-1'),
TenantGroup(name='Tenant group 2', slug='tenant-group-2'),
TenantGroup(name='Tenant group 3', slug='tenant-group-3'),
)
TenantGroup.objects.bulk_create(tenant_groups)
tenants = (
Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]),
Tenant(name='Tenant 2', slug='tenant-2', group=tenant_groups[1]),
Tenant(name='Tenant 3', slug='tenant-3', group=tenant_groups[2]),
)
Tenant.objects.bulk_create(tenants)
circuit_types = (
CircuitType(name='Test Circuit Type 1', slug='test-circuit-type-1'),
CircuitType(name='Test Circuit Type 2', slug='test-circuit-type-2'),
@@ -151,12 +166,12 @@ class CircuitTestCase(TestCase):
Provider.objects.bulk_create(providers)
circuits = (
Circuit(provider=providers[0], type=circuit_types[0], cid='Test Circuit 1', install_date='2020-01-01', commit_rate=1000, status=CircuitStatusChoices.STATUS_ACTIVE),
Circuit(provider=providers[0], type=circuit_types[0], cid='Test Circuit 2', install_date='2020-01-02', commit_rate=2000, status=CircuitStatusChoices.STATUS_ACTIVE),
Circuit(provider=providers[0], type=circuit_types[0], cid='Test Circuit 3', install_date='2020-01-03', commit_rate=3000, status=CircuitStatusChoices.STATUS_PLANNED),
Circuit(provider=providers[1], type=circuit_types[1], cid='Test Circuit 4', install_date='2020-01-04', commit_rate=4000, status=CircuitStatusChoices.STATUS_PLANNED),
Circuit(provider=providers[1], type=circuit_types[1], cid='Test Circuit 5', install_date='2020-01-05', commit_rate=5000, status=CircuitStatusChoices.STATUS_OFFLINE),
Circuit(provider=providers[1], type=circuit_types[1], cid='Test Circuit 6', install_date='2020-01-06', commit_rate=6000, status=CircuitStatusChoices.STATUS_OFFLINE),
Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 1', install_date='2020-01-01', commit_rate=1000, status=CircuitStatusChoices.STATUS_ACTIVE),
Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 2', install_date='2020-01-02', commit_rate=2000, status=CircuitStatusChoices.STATUS_ACTIVE),
Circuit(provider=providers[0], tenant=tenants[1], type=circuit_types[0], cid='Test Circuit 3', install_date='2020-01-03', commit_rate=3000, status=CircuitStatusChoices.STATUS_PLANNED),
Circuit(provider=providers[1], tenant=tenants[1], type=circuit_types[1], cid='Test Circuit 4', install_date='2020-01-04', commit_rate=4000, status=CircuitStatusChoices.STATUS_PLANNED),
Circuit(provider=providers[1], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 5', install_date='2020-01-05', commit_rate=5000, status=CircuitStatusChoices.STATUS_OFFLINE),
Circuit(provider=providers[1], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 6', install_date='2020-01-06', commit_rate=6000, status=CircuitStatusChoices.STATUS_OFFLINE),
)
Circuit.objects.bulk_create(circuits)
@@ -216,6 +231,20 @@ class CircuitTestCase(TestCase):
params = {'site': [sites[0].slug, sites[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_tenant(self):
tenants = Tenant.objects.all()[:2]
params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'tenant': [tenants[0].slug, tenants[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_tenant_group(self):
tenant_groups = TenantGroup.objects.all()[:2]
params = {'tenant_group_id': [tenant_groups[0].pk, tenant_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
class CircuitTerminationTestCase(TestCase):
queryset = CircuitTermination.objects.all()

View File

@@ -29,7 +29,6 @@ class ProviderListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.ProviderFilterSet
filterset_form = forms.ProviderFilterForm
table = tables.ProviderDetailTable
template_name = 'circuits/provider_list.html'
class ProviderView(PermissionRequiredMixin, View):
@@ -38,10 +37,14 @@ class ProviderView(PermissionRequiredMixin, View):
def get(self, request, slug):
provider = get_object_or_404(Provider, slug=slug)
circuits = Circuit.objects.filter(provider=provider).prefetch_related('type', 'tenant', 'terminations__site')
circuits = Circuit.objects.filter(
provider=provider
).prefetch_related(
'type', 'tenant', 'terminations__site'
).annotate_sites()
show_graphs = Graph.objects.filter(type__model='provider').exists()
circuits_table = tables.CircuitTable(circuits, orderable=False)
circuits_table = tables.CircuitTable(circuits)
circuits_table.columns.hide('provider')
paginate = {
@@ -107,7 +110,6 @@ class CircuitTypeListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'circuits.view_circuittype'
queryset = CircuitType.objects.annotate(circuit_count=Count('circuits'))
table = tables.CircuitTypeTable
template_name = 'circuits/circuittype_list.html'
class CircuitTypeCreateView(PermissionRequiredMixin, ObjectEditView):
@@ -144,14 +146,10 @@ class CircuitListView(PermissionRequiredMixin, ObjectListView):
_terminations = CircuitTermination.objects.filter(circuit=OuterRef('pk'))
queryset = Circuit.objects.prefetch_related(
'provider', 'type', 'tenant', 'terminations__site'
).annotate(
a_side=Subquery(_terminations.filter(term_side='A').values('site__name')[:1]),
z_side=Subquery(_terminations.filter(term_side='Z').values('site__name')[:1]),
)
).annotate_sites()
filterset = filters.CircuitFilterSet
filterset_form = forms.CircuitFilterForm
table = tables.CircuitTable
template_name = 'circuits/circuit_list.html'
class CircuitView(PermissionRequiredMixin, View):

View File

@@ -3,8 +3,8 @@ from rest_framework import serializers
from dcim.constants import CONNECTION_STATUS_CHOICES
from dcim.models import (
Cable, ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceType, DeviceRole, FrontPort, FrontPortTemplate,
Interface, Manufacturer, Platform, PowerFeed, PowerOutlet, PowerPanel, PowerPort, Rack, RackGroup, RackRole,
RearPort, RearPortTemplate, Region, Site, VirtualChassis,
Interface, Manufacturer, Platform, PowerFeed, PowerOutlet, PowerPanel, PowerPort, PowerPortTemplate, Rack,
RackGroup, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis,
)
from utilities.api import ChoiceField, WritableNestedSerializer
@@ -25,6 +25,7 @@ __all__ = [
'NestedPowerOutletSerializer',
'NestedPowerPanelSerializer',
'NestedPowerPortSerializer',
'NestedPowerPortTemplateSerializer',
'NestedRackGroupSerializer',
'NestedRackRoleSerializer',
'NestedRackSerializer',
@@ -111,6 +112,14 @@ class NestedDeviceTypeSerializer(WritableNestedSerializer):
fields = ['id', 'url', 'manufacturer', 'model', 'slug', 'display_name', 'device_count']
class NestedPowerPortTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerporttemplate-detail')
class Meta:
model = PowerPortTemplate
fields = ['id', 'url', 'name']
class NestedRearPortTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearporttemplate-detail')

View File

@@ -172,6 +172,10 @@ class RackReservationSerializer(ValidatedModelSerializer):
class RackElevationDetailFilterSerializer(serializers.Serializer):
q = serializers.CharField(
required=False,
default=None
)
face = serializers.ChoiceField(
choices=DeviceFaceChoices,
default=DeviceFaceChoices.FACE_FRONT
@@ -186,6 +190,9 @@ class RackElevationDetailFilterSerializer(serializers.Serializer):
unit_height = serializers.IntegerField(
default=RACK_ELEVATION_UNIT_HEIGHT_DEFAULT
)
legend_width = serializers.IntegerField(
default=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT
)
exclude = serializers.IntegerField(
required=False,
default=None
@@ -194,6 +201,10 @@ class RackElevationDetailFilterSerializer(serializers.Serializer):
required=False,
default=True
)
include_images = serializers.BooleanField(
required=False,
default=True
)
#
@@ -220,7 +231,8 @@ class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer):
model = DeviceType
fields = [
'id', 'manufacturer', 'model', 'slug', 'display_name', 'part_number', 'u_height', 'is_full_depth',
'subdevice_role', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count',
'subdevice_role', 'front_image', 'rear_image', 'comments', 'tags', 'custom_fields', 'created',
'last_updated', 'device_count',
]
@@ -270,7 +282,7 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
allow_blank=True,
required=False
)
power_port = PowerPortTemplateSerializer(
power_port = NestedPowerPortTemplateSerializer(
required=False
)
feed_leg = ChoiceField(
@@ -286,7 +298,7 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
class InterfaceTemplateSerializer(ValidatedModelSerializer):
device_type = NestedDeviceTypeSerializer()
type = ChoiceField(choices=InterfaceTypeChoices, required=False)
type = ChoiceField(choices=InterfaceTypeChoices)
class Meta:
model = InterfaceTemplate
@@ -506,7 +518,7 @@ class PowerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer):
device = NestedDeviceSerializer()
type = ChoiceField(choices=InterfaceTypeChoices, required=False)
type = ChoiceField(choices=InterfaceTypeChoices)
lag = NestedInterfaceSerializer(required=False, allow_null=True)
mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False)
untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)

View File

@@ -220,7 +220,13 @@ class RackViewSet(CustomFieldModelViewSet):
if data['render'] == 'svg':
# Render and return the elevation as an SVG drawing with the correct content type
drawing = rack.get_elevation_svg(data['face'], data['unit_width'], data['unit_height'])
drawing = rack.get_elevation_svg(
face=data['face'],
unit_width=data['unit_width'],
unit_height=data['unit_height'],
legend_width=data['legend_width'],
include_images=data['include_images']
)
return HttpResponse(drawing.tostring(), content_type='image/svg+xml')
else:
@@ -231,6 +237,11 @@ class RackViewSet(CustomFieldModelViewSet):
expand_devices=data['expand_devices']
)
# Enable filtering rack units by ID
q = data['q']
if q:
elevation = [u for u in elevation if q in str(u['id']) or q in str(u['name'])]
page = self.paginate_queryset(elevation)
if page is not None:
rack_units = serializers.RackUnitSerializer(page, many=True, context={'request': request})

View File

@@ -55,10 +55,12 @@ class RackTypeChoices(ChoiceSet):
class RackWidthChoices(ChoiceSet):
WIDTH_10IN = 10
WIDTH_19IN = 19
WIDTH_23IN = 23
CHOICES = (
(WIDTH_10IN, '10 inches'),
(WIDTH_19IN, '19 inches'),
(WIDTH_23IN, '23 inches'),
)
@@ -195,6 +197,7 @@ class ConsolePortTypeChoices(ChoiceSet):
TYPE_DE9 = 'de-9'
TYPE_DB25 = 'db-25'
TYPE_RJ11 = 'rj-11'
TYPE_RJ12 = 'rj-12'
TYPE_RJ45 = 'rj-45'
TYPE_USB_A = 'usb-a'
@@ -210,6 +213,7 @@ class ConsolePortTypeChoices(ChoiceSet):
('Serial', (
(TYPE_DE9, 'DE-9'),
(TYPE_DB25, 'DB-25'),
(TYPE_RJ11, 'RJ-11'),
(TYPE_RJ12, 'RJ-12'),
(TYPE_RJ45, 'RJ-45'),
)),
@@ -834,6 +838,7 @@ class PortTypeChoices(ChoiceSet):
TYPE_8P8C = '8p8c'
TYPE_110_PUNCH = '110-punch'
TYPE_BNC = 'bnc'
TYPE_MRJ21 = 'mrj21'
TYPE_ST = 'st'
TYPE_SC = 'sc'
TYPE_SC_APC = 'sc-apc'
@@ -852,6 +857,7 @@ class PortTypeChoices(ChoiceSet):
(TYPE_8P8C, '8P8C'),
(TYPE_110_PUNCH, '110 Punch'),
(TYPE_BNC, 'BNC'),
(TYPE_MRJ21, 'MRJ21'),
),
),
(
@@ -902,6 +908,7 @@ class CableTypeChoices(ChoiceSet):
TYPE_CAT7 = 'cat7'
TYPE_DAC_ACTIVE = 'dac-active'
TYPE_DAC_PASSIVE = 'dac-passive'
TYPE_MRJ21_TRUNK = 'mrj21-trunk'
TYPE_COAXIAL = 'coaxial'
TYPE_MMF = 'mmf'
TYPE_MMF_OM1 = 'mmf-om1'
@@ -925,6 +932,7 @@ class CableTypeChoices(ChoiceSet):
(TYPE_CAT7, 'CAT7'),
(TYPE_DAC_ACTIVE, 'Direct Attach Copper (Active)'),
(TYPE_DAC_PASSIVE, 'Direct Attach Copper (Passive)'),
(TYPE_MRJ21_TRUNK, 'MRJ21 Trunk'),
(TYPE_COAXIAL, 'Coaxial'),
),
),
@@ -971,10 +979,12 @@ class CableStatusChoices(ChoiceSet):
STATUS_CONNECTED = 'connected'
STATUS_PLANNED = 'planned'
STATUS_DECOMMISSIONING = 'decommissioning'
CHOICES = (
(STATUS_CONNECTED, 'Connected'),
(STATUS_PLANNED, 'Planned'),
(STATUS_DECOMMISSIONING, 'Decommissioning'),
)
LEGACY_MAP = {

View File

@@ -9,10 +9,10 @@ from .choices import InterfaceTypeChoices
RACK_U_HEIGHT_DEFAULT = 42
RACK_ELEVATION_BORDER_WIDTH = 2
RACK_ELEVATION_LEGEND_WIDTH_DEFAULT = 30
RACK_ELEVATION_UNIT_WIDTH_DEFAULT = 230
RACK_ELEVATION_UNIT_HEIGHT_DEFAULT = 20
RACK_ELEVATION_UNIT_WIDTH_DEFAULT = 220
RACK_ELEVATION_UNIT_HEIGHT_DEFAULT = 22
#
@@ -61,13 +61,10 @@ POWERFEED_MAX_UTILIZATION_DEFAULT = 80 # Percentage
# Cabling and connections
#
# TODO: Replace with CableStatusChoices?
# Console/power/interface connection statuses
CONNECTION_STATUS_PLANNED = False
CONNECTION_STATUS_CONNECTED = True
CONNECTION_STATUS_CHOICES = [
[CONNECTION_STATUS_PLANNED, 'Planned'],
[CONNECTION_STATUS_CONNECTED, 'Connected'],
[False, 'Not Connected'],
[True, 'Connected'],
]
# Cable endpoint types

208
netbox/dcim/elevations.py Normal file
View File

@@ -0,0 +1,208 @@
import svgwrite
from django.conf import settings
from django.urls import reverse
from django.utils.http import urlencode
from utilities.utils import foreground_color
from .choices import DeviceFaceChoices
from .constants import RACK_ELEVATION_BORDER_WIDTH
class RackElevationSVG:
"""
Use this class to render a rack elevation as an SVG image.
:param rack: A NetBox Rack instance
:param include_images: If true, the SVG document will embed front/rear device face images, where available
"""
def __init__(self, rack, include_images=True):
self.rack = rack
self.include_images = include_images
def _get_device_description(self, device):
return '{} ({}) — {} ({}U) {} {}'.format(
device.name,
device.device_role,
device.device_type.display_name,
device.device_type.u_height,
device.asset_tag or '',
device.serial or ''
)
@staticmethod
def _add_gradient(drawing, id_, color):
gradient = drawing.linearGradient(
start=(0, 0),
end=(0, 25),
spreadMethod='repeat',
id_=id_,
gradientTransform='rotate(45, 0, 0)',
gradientUnits='userSpaceOnUse'
)
gradient.add_stop_color(offset='0%', color='#f7f7f7')
gradient.add_stop_color(offset='50%', color='#f7f7f7')
gradient.add_stop_color(offset='50%', color=color)
gradient.add_stop_color(offset='100%', color=color)
drawing.defs.add(gradient)
@staticmethod
def _setup_drawing(width, height):
drawing = svgwrite.Drawing(size=(width, height))
# add the stylesheet
with open('{}/css/rack_elevation.css'.format(settings.STATICFILES_DIRS[0])) as css_file:
drawing.defs.add(drawing.style(css_file.read()))
# add gradients
RackElevationSVG._add_gradient(drawing, 'reserved', '#c7c7ff')
RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7')
RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0')
return drawing
def _draw_device_front(self, drawing, device, start, end, text):
name = str(device)
if device.devicebay_count:
name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count)
color = device.device_role.color
link = drawing.add(
drawing.a(
href=reverse('dcim:device', kwargs={'pk': device.pk}),
target='_top',
fill='black'
)
)
link.set_desc(self._get_device_description(device))
link.add(drawing.rect(start, end, style='fill: #{}'.format(color), class_='slot'))
hex_color = '#{}'.format(foreground_color(color))
link.add(drawing.text(str(name), insert=text, fill=hex_color))
# Embed front device type image if one exists
if self.include_images and device.device_type.front_image:
url = device.device_type.front_image.url
image = drawing.image(href=url, insert=start, size=end, class_='device-image')
image.fit(scale='slice')
link.add(image)
def _draw_device_rear(self, drawing, device, start, end, text):
rect = drawing.rect(start, end, class_="slot blocked")
rect.set_desc(self._get_device_description(device))
drawing.add(rect)
drawing.add(drawing.text(str(device), insert=text))
# Embed rear device type image if one exists
if self.include_images and device.device_type.rear_image:
url = device.device_type.rear_image.url
image = drawing.image(href=url, insert=start, size=end, class_='device-image')
image.fit(scale='slice')
drawing.add(image)
@staticmethod
def _draw_empty(drawing, rack, start, end, text, id_, face_id, class_, reservation):
link = drawing.add(
drawing.a(
href='{}?{}'.format(
reverse('dcim:device_add'),
urlencode({'rack': rack.pk, 'site': rack.site.pk, 'face': face_id, 'position': id_})
),
target='_top'
)
)
if reservation:
link.set_desc('{}{} · {}'.format(
reservation.description, reservation.user, reservation.created
))
link.add(drawing.rect(start, end, class_=class_))
link.add(drawing.text("add device", insert=text, class_='add-device'))
def merge_elevations(self, face):
elevation = self.rack.get_rack_units(face=face, expand_devices=False)
if face == DeviceFaceChoices.FACE_REAR:
other_face = DeviceFaceChoices.FACE_FRONT
else:
other_face = DeviceFaceChoices.FACE_REAR
other = self.rack.get_rack_units(face=other_face)
unit_cursor = 0
for u in elevation:
o = other[unit_cursor]
if not u['device'] and o['device']:
u['device'] = o['device']
u['height'] = 1
unit_cursor += u.get('height', 1)
return elevation
def render(self, face, unit_width, unit_height, legend_width):
"""
Return an SVG document representing a rack elevation.
"""
drawing = self._setup_drawing(
unit_width + legend_width + RACK_ELEVATION_BORDER_WIDTH * 2,
unit_height * self.rack.u_height + RACK_ELEVATION_BORDER_WIDTH * 2
)
reserved_units = self.rack.get_reserved_units()
unit_cursor = 0
for ru in range(0, self.rack.u_height):
start_y = ru * unit_height
position_coordinates = (legend_width / 2, start_y + unit_height / 2 + RACK_ELEVATION_BORDER_WIDTH)
unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru
drawing.add(
drawing.text(str(unit), position_coordinates, class_="unit")
)
for unit in self.merge_elevations(face):
# Loop through all units in the elevation
device = unit['device']
height = unit.get('height', 1)
# Setup drawing coordinates
x_offset = legend_width + RACK_ELEVATION_BORDER_WIDTH
y_offset = unit_cursor * unit_height + RACK_ELEVATION_BORDER_WIDTH
end_y = unit_height * height
start_cordinates = (x_offset, y_offset)
end_cordinates = (unit_width, end_y)
text_cordinates = (x_offset + (unit_width / 2), y_offset + end_y / 2)
# Draw the device
if device and device.face == face:
self._draw_device_front(drawing, device, start_cordinates, end_cordinates, text_cordinates)
elif device and device.device_type.is_full_depth:
self._draw_device_rear(drawing, device, start_cordinates, end_cordinates, text_cordinates)
else:
# Draw shallow devices, reservations, or empty units
class_ = 'slot'
reservation = reserved_units.get(unit["id"])
if device:
class_ += ' occupied'
if reservation:
class_ += ' reserved'
self._draw_empty(
drawing,
self.rack,
start_cordinates,
end_cordinates,
text_cordinates,
unit["id"],
face,
class_,
reservation
)
unit_cursor += height
# Wrap the drawing with a border
border_width = RACK_ELEVATION_BORDER_WIDTH
border_offset = RACK_ELEVATION_BORDER_WIDTH / 2
frame = drawing.rect(
insert=(legend_width + border_offset, border_offset),
size=(unit_width + border_width, self.rack.u_height * unit_height + border_width),
class_='rack'
)
drawing.add(frame)
return drawing

View File

@@ -6,8 +6,8 @@ from tenancy.filters import TenancyFilterSet
from tenancy.models import Tenant
from utilities.constants import COLOR_CHOICES
from utilities.filters import (
MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, NumericInFilter,
TagFilter, TreeNodeMultipleChoiceFilter,
BaseFilterSet, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter,
NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter,
)
from virtualization.models import Cluster
from .choices import *
@@ -60,7 +60,7 @@ __all__ = (
)
class RegionFilterSet(NameSlugSearchFilterSet):
class RegionFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
parent_id = django_filters.ModelMultipleChoiceFilter(
queryset=Region.objects.all(),
label='Parent region (ID)',
@@ -77,7 +77,7 @@ class RegionFilterSet(NameSlugSearchFilterSet):
fields = ['id', 'name', 'slug']
class SiteFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
class SiteFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
id__in = NumericInFilter(
field_name='id',
lookup_expr='in'
@@ -92,12 +92,14 @@ class SiteFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilter
)
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='region__in',
field_name='region',
lookup_expr='in',
label='Region (ID)',
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='region__in',
field_name='region',
lookup_expr='in',
to_field_name='slug',
label='Region (slug)',
)
@@ -131,15 +133,17 @@ class SiteFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilter
return queryset.filter(qs_filter)
class RackGroupFilterSet(NameSlugSearchFilterSet):
class RackGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region__in',
field_name='site__region',
lookup_expr='in',
label='Region (ID)',
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region__in',
field_name='site__region',
lookup_expr='in',
to_field_name='slug',
label='Region (slug)',
)
@@ -159,14 +163,14 @@ class RackGroupFilterSet(NameSlugSearchFilterSet):
fields = ['id', 'name', 'slug']
class RackRoleFilterSet(NameSlugSearchFilterSet):
class RackRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
class Meta:
model = RackRole
fields = ['id', 'name', 'slug', 'color']
class RackFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
id__in = NumericInFilter(
field_name='id',
lookup_expr='in'
@@ -177,12 +181,14 @@ class RackFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilter
)
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region__in',
field_name='site__region',
lookup_expr='in',
label='Region (ID)',
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region__in',
field_name='site__region',
lookup_expr='in',
to_field_name='slug',
label='Region (slug)',
)
@@ -244,7 +250,7 @@ class RackFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilter
)
class RackReservationFilterSet(TenancyFilterSet):
class RackReservationFilterSet(BaseFilterSet, TenancyFilterSet):
id__in = NumericInFilter(
field_name='id',
lookup_expr='in'
@@ -305,14 +311,14 @@ class RackReservationFilterSet(TenancyFilterSet):
)
class ManufacturerFilterSet(NameSlugSearchFilterSet):
class ManufacturerFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
class Meta:
model = Manufacturer
fields = ['id', 'name', 'slug']
class DeviceTypeFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet):
class DeviceTypeFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
id__in = NumericInFilter(
field_name='id',
lookup_expr='in'
@@ -410,70 +416,70 @@ class DeviceTypeComponentFilterSet(NameSlugSearchFilterSet):
)
class ConsolePortTemplateFilterSet(DeviceTypeComponentFilterSet):
class ConsolePortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
class Meta:
model = ConsolePortTemplate
fields = ['id', 'name', 'type']
class ConsoleServerPortTemplateFilterSet(DeviceTypeComponentFilterSet):
class ConsoleServerPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
class Meta:
model = ConsoleServerPortTemplate
fields = ['id', 'name', 'type']
class PowerPortTemplateFilterSet(DeviceTypeComponentFilterSet):
class PowerPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
class Meta:
model = PowerPortTemplate
fields = ['id', 'name', 'type', 'maximum_draw', 'allocated_draw']
class PowerOutletTemplateFilterSet(DeviceTypeComponentFilterSet):
class PowerOutletTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
class Meta:
model = PowerOutletTemplate
fields = ['id', 'name', 'type', 'feed_leg']
class InterfaceTemplateFilterSet(DeviceTypeComponentFilterSet):
class InterfaceTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
class Meta:
model = InterfaceTemplate
fields = ['id', 'name', 'type', 'mgmt_only']
class FrontPortTemplateFilterSet(DeviceTypeComponentFilterSet):
class FrontPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
class Meta:
model = FrontPortTemplate
fields = ['id', 'name', 'type']
class RearPortTemplateFilterSet(DeviceTypeComponentFilterSet):
class RearPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
class Meta:
model = RearPortTemplate
fields = ['id', 'name', 'type', 'positions']
class DeviceBayTemplateFilterSet(DeviceTypeComponentFilterSet):
class DeviceBayTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
class Meta:
model = DeviceBayTemplate
fields = ['id', 'name']
class DeviceRoleFilterSet(NameSlugSearchFilterSet):
class DeviceRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
class Meta:
model = DeviceRole
fields = ['id', 'name', 'slug', 'color', 'vm_role']
class PlatformFilterSet(NameSlugSearchFilterSet):
class PlatformFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
field_name='manufacturer',
queryset=Manufacturer.objects.all(),
@@ -491,7 +497,13 @@ class PlatformFilterSet(NameSlugSearchFilterSet):
fields = ['id', 'name', 'slug', 'napalm_driver']
class DeviceFilterSet(LocalConfigContextFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
class DeviceFilterSet(
BaseFilterSet,
TenancyFilterSet,
LocalConfigContextFilterSet,
CustomFieldFilterSet,
CreatedUpdatedFilterSet
):
id__in = NumericInFilter(
field_name='id',
lookup_expr='in'
@@ -538,12 +550,14 @@ class DeviceFilterSet(LocalConfigContextFilterSet, TenancyFilterSet, CustomField
)
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region__in',
field_name='site__region',
lookup_expr='in',
label='Region (ID)',
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region__in',
field_name='site__region',
lookup_expr='in',
to_field_name='slug',
label='Region (slug)',
)
@@ -697,12 +711,14 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
)
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='device__site__region__in',
field_name='device__site__region',
lookup_expr='in',
label='Region (ID)',
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='device__site__region__in',
field_name='device__site__region',
lookup_expr='in',
to_field_name='slug',
label='Region (slug)',
)
@@ -738,7 +754,7 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
)
class ConsolePortFilterSet(DeviceComponentFilterSet):
class ConsolePortFilterSet(BaseFilterSet, DeviceComponentFilterSet):
type = django_filters.MultipleChoiceFilter(
choices=ConsolePortTypeChoices,
null_value=None
@@ -754,7 +770,7 @@ class ConsolePortFilterSet(DeviceComponentFilterSet):
fields = ['id', 'name', 'description', 'connection_status']
class ConsoleServerPortFilterSet(DeviceComponentFilterSet):
class ConsoleServerPortFilterSet(BaseFilterSet, DeviceComponentFilterSet):
type = django_filters.MultipleChoiceFilter(
choices=ConsolePortTypeChoices,
null_value=None
@@ -770,7 +786,7 @@ class ConsoleServerPortFilterSet(DeviceComponentFilterSet):
fields = ['id', 'name', 'description', 'connection_status']
class PowerPortFilterSet(DeviceComponentFilterSet):
class PowerPortFilterSet(BaseFilterSet, DeviceComponentFilterSet):
type = django_filters.MultipleChoiceFilter(
choices=PowerPortTypeChoices,
null_value=None
@@ -786,7 +802,7 @@ class PowerPortFilterSet(DeviceComponentFilterSet):
fields = ['id', 'name', 'maximum_draw', 'allocated_draw', 'description', 'connection_status']
class PowerOutletFilterSet(DeviceComponentFilterSet):
class PowerOutletFilterSet(BaseFilterSet, DeviceComponentFilterSet):
type = django_filters.MultipleChoiceFilter(
choices=PowerOutletTypeChoices,
null_value=None
@@ -802,7 +818,7 @@ class PowerOutletFilterSet(DeviceComponentFilterSet):
fields = ['id', 'name', 'feed_leg', 'description', 'connection_status']
class InterfaceFilterSet(DeviceComponentFilterSet):
class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@@ -900,7 +916,7 @@ class InterfaceFilterSet(DeviceComponentFilterSet):
}.get(value, queryset.none())
class FrontPortFilterSet(DeviceComponentFilterSet):
class FrontPortFilterSet(BaseFilterSet, DeviceComponentFilterSet):
cabled = django_filters.BooleanFilter(
field_name='cable',
lookup_expr='isnull',
@@ -912,7 +928,7 @@ class FrontPortFilterSet(DeviceComponentFilterSet):
fields = ['id', 'name', 'type', 'description']
class RearPortFilterSet(DeviceComponentFilterSet):
class RearPortFilterSet(BaseFilterSet, DeviceComponentFilterSet):
cabled = django_filters.BooleanFilter(
field_name='cable',
lookup_expr='isnull',
@@ -924,26 +940,28 @@ class RearPortFilterSet(DeviceComponentFilterSet):
fields = ['id', 'name', 'type', 'positions', 'description']
class DeviceBayFilterSet(DeviceComponentFilterSet):
class DeviceBayFilterSet(BaseFilterSet, DeviceComponentFilterSet):
class Meta:
model = DeviceBay
fields = ['id', 'name', 'description']
class InventoryItemFilterSet(DeviceComponentFilterSet):
class InventoryItemFilterSet(BaseFilterSet, DeviceComponentFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='device__site__region__in',
field_name='device__site__region',
lookup_expr='in',
label='Region (ID)',
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='device__site__region__in',
field_name='device__site__region',
lookup_expr='in',
to_field_name='slug',
label='Region (slug)',
)
@@ -1002,19 +1020,21 @@ class InventoryItemFilterSet(DeviceComponentFilterSet):
return queryset.filter(qs_filter)
class VirtualChassisFilterSet(django_filters.FilterSet):
class VirtualChassisFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='master__site__region__in',
field_name='master__site__region',
lookup_expr='in',
label='Region (ID)',
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='master__site__region__in',
field_name='master__site__region',
lookup_expr='in',
to_field_name='slug',
label='Region (slug)',
)
@@ -1056,7 +1076,7 @@ class VirtualChassisFilterSet(django_filters.FilterSet):
return queryset.filter(qs_filter)
class CableFilterSet(django_filters.FilterSet):
class CableFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@@ -1119,7 +1139,7 @@ class CableFilterSet(django_filters.FilterSet):
return queryset
class ConsoleConnectionFilterSet(django_filters.FilterSet):
class ConsoleConnectionFilterSet(BaseFilterSet):
site = django_filters.CharFilter(
method='filter_site',
label='Site (slug)',
@@ -1150,7 +1170,7 @@ class ConsoleConnectionFilterSet(django_filters.FilterSet):
)
class PowerConnectionFilterSet(django_filters.FilterSet):
class PowerConnectionFilterSet(BaseFilterSet):
site = django_filters.CharFilter(
method='filter_site',
label='Site (slug)',
@@ -1181,7 +1201,7 @@ class PowerConnectionFilterSet(django_filters.FilterSet):
)
class InterfaceConnectionFilterSet(django_filters.FilterSet):
class InterfaceConnectionFilterSet(BaseFilterSet):
site = django_filters.CharFilter(
method='filter_site',
label='Site (slug)',
@@ -1215,7 +1235,7 @@ class InterfaceConnectionFilterSet(django_filters.FilterSet):
)
class PowerPanelFilterSet(django_filters.FilterSet):
class PowerPanelFilterSet(BaseFilterSet):
id__in = NumericInFilter(
field_name='id',
lookup_expr='in'
@@ -1226,12 +1246,14 @@ class PowerPanelFilterSet(django_filters.FilterSet):
)
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region__in',
field_name='site__region',
lookup_expr='in',
label='Region (ID)',
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region__in',
field_name='site__region',
lookup_expr='in',
to_field_name='slug',
label='Region (slug)',
)
@@ -1264,7 +1286,7 @@ class PowerPanelFilterSet(django_filters.FilterSet):
return queryset.filter(qs_filter)
class PowerFeedFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet):
class PowerFeedFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
id__in = NumericInFilter(
field_name='id',
lookup_expr='in'
@@ -1275,12 +1297,14 @@ class PowerFeedFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet):
)
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='power_panel__site__region__in',
field_name='power_panel__site__region',
lookup_expr='in',
label='Region (ID)',
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='power_panel__site__region__in',
field_name='power_panel__site__region',
lookup_expr='in',
to_field_name='slug',
label='Region (slug)',
)

View File

@@ -385,7 +385,6 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
class RackGroupForm(BootstrapMixin, forms.ModelForm):
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False,
widget=APISelect(
api_url="/api/dcim/sites/"
)
@@ -931,8 +930,8 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm):
class Meta:
model = DeviceType
fields = [
'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'comments',
'tags',
'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
'front_image', 'rear_image', 'comments', 'tags',
]
widgets = {
'subdevice_role': StaticSelect2()
@@ -2345,6 +2344,11 @@ class DeviceBulkAddInterfaceForm(DeviceBulkAddComponentForm):
class ConsolePortFilterForm(DeviceComponentFilterForm):
model = ConsolePort
type = forms.MultipleChoiceField(
choices=ConsolePortTypeChoices,
required=False,
widget=StaticSelect2Multiple()
)
tag = TagFilterField(model)
@@ -2430,6 +2434,11 @@ class ConsolePortCSVForm(forms.ModelForm):
class ConsoleServerPortFilterForm(DeviceComponentFilterForm):
model = ConsoleServerPort
type = forms.MultipleChoiceField(
choices=ConsolePortTypeChoices,
required=False,
widget=StaticSelect2Multiple()
)
tag = TagFilterField(model)
@@ -2529,6 +2538,11 @@ class ConsoleServerPortCSVForm(forms.ModelForm):
class PowerPortFilterForm(DeviceComponentFilterForm):
model = PowerPort
type = forms.MultipleChoiceField(
choices=PowerPortTypeChoices,
required=False,
widget=StaticSelect2Multiple()
)
tag = TagFilterField(model)
@@ -2634,6 +2648,11 @@ class PowerPortCSVForm(forms.ModelForm):
class PowerOutletFilterForm(DeviceComponentFilterForm):
model = PowerOutlet
type = forms.MultipleChoiceField(
choices=PowerOutletTypeChoices,
required=False,
widget=StaticSelect2Multiple()
)
tag = TagFilterField(model)
@@ -2764,6 +2783,7 @@ class PowerOutletBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
device = forms.ModelChoiceField(
queryset=Device.objects.all(),
required=False,
disabled=True,
widget=forms.HiddenInput()
)
type = forms.ChoiceField(
@@ -2821,6 +2841,17 @@ class PowerOutletBulkDisconnectForm(ConfirmationForm):
class InterfaceFilterForm(DeviceComponentFilterForm):
model = Interface
type = forms.MultipleChoiceField(
choices=InterfaceTypeChoices,
required=False,
widget=StaticSelect2Multiple()
)
enabled = forms.NullBooleanField(
required=False,
widget=StaticSelect2(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
tag = TagFilterField(model)
@@ -2832,7 +2863,10 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm):
widget=APISelect(
api_url="/api/ipam/vlans/",
display_field='display_name',
full=True
full=True,
additional_query_params={
'site_id': 'null',
},
)
)
tagged_vlans = DynamicModelMultipleChoiceField(
@@ -2842,7 +2876,10 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm):
widget=APISelectMultiple(
api_url="/api/ipam/vlans/",
display_field='display_name',
full=True
full=True,
additional_query_params={
'site_id': 'null',
},
)
)
tags = TagField(
@@ -2871,18 +2908,20 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Limit LAG choices to interfaces belonging to this device (or VC master)
if self.is_bound:
device = Device.objects.get(pk=self.data['device'])
self.fields['lag'].queryset = Interface.objects.filter(
device__in=[device, device.get_vc_master()],
type=InterfaceTypeChoices.TYPE_LAG
)
else:
self.fields['lag'].queryset = Interface.objects.filter(
device__in=[self.instance.device, self.instance.device.get_vc_master()],
type=InterfaceTypeChoices.TYPE_LAG
)
device = self.instance.device
# Limit LAG choices to interfaces belonging to this device (or VC master)
self.fields['lag'].queryset = Interface.objects.filter(
device__in=[device, device.get_vc_master()],
type=InterfaceTypeChoices.TYPE_LAG
)
# Add current site to VLANs query params
self.fields['untagged_vlan'].widget.add_additional_query_param('site_id', device.site.pk)
self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', device.site.pk)
class InterfaceCreateForm(BootstrapMixin, InterfaceCommonForm, forms.Form):
@@ -2942,7 +2981,10 @@ class InterfaceCreateForm(BootstrapMixin, InterfaceCommonForm, forms.Form):
widget=APISelect(
api_url="/api/ipam/vlans/",
display_field='display_name',
full=True
full=True,
additional_query_params={
'site_id': 'null',
},
)
)
tagged_vlans = DynamicModelMultipleChoiceField(
@@ -2951,7 +2993,10 @@ class InterfaceCreateForm(BootstrapMixin, InterfaceCommonForm, forms.Form):
widget=APISelectMultiple(
api_url="/api/ipam/vlans/",
display_field='display_name',
full=True
full=True,
additional_query_params={
'site_id': 'null',
},
)
)
@@ -2967,6 +3012,10 @@ class InterfaceCreateForm(BootstrapMixin, InterfaceCommonForm, forms.Form):
type=InterfaceTypeChoices.TYPE_LAG
)
# Add current site to VLANs query params
self.fields['untagged_vlan'].widget.add_additional_query_param('site_id', device.site.pk)
self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', device.site.pk)
class InterfaceCSVForm(forms.ModelForm):
device = FlexibleModelChoiceField(
@@ -3043,6 +3092,7 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
device = forms.ModelChoiceField(
queryset=Device.objects.all(),
required=False,
disabled=True,
widget=forms.HiddenInput()
)
type = forms.ChoiceField(
@@ -3090,7 +3140,10 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
widget=APISelect(
api_url="/api/ipam/vlans/",
display_field='display_name',
full=True
full=True,
additional_query_params={
'site_id': 'null',
},
)
)
tagged_vlans = DynamicModelMultipleChoiceField(
@@ -3099,7 +3152,10 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
widget=APISelectMultiple(
api_url="/api/ipam/vlans/",
display_field='display_name',
full=True
full=True,
additional_query_params={
'site_id': 'null',
},
)
)
@@ -3118,6 +3174,10 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
device__in=[device, device.get_vc_master()],
type=InterfaceTypeChoices.TYPE_LAG
)
# Add current site to VLANs query params
self.fields['untagged_vlan'].widget.add_additional_query_param('site_id', device.site.pk)
self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', device.site.pk)
else:
self.fields['lag'].choices = ()
self.fields['lag'].widget.attrs['disabled'] = True
@@ -3155,6 +3215,11 @@ class InterfaceBulkDisconnectForm(ConfirmationForm):
class FrontPortFilterForm(DeviceComponentFilterForm):
model = FrontPort
type = forms.MultipleChoiceField(
choices=PortTypeChoices,
required=False,
widget=StaticSelect2Multiple()
)
tag = TagFilterField(model)
@@ -3344,6 +3409,11 @@ class FrontPortBulkDisconnectForm(ConfirmationForm):
class RearPortFilterForm(DeviceComponentFilterForm):
model = RearPort
type = forms.MultipleChoiceField(
choices=PortTypeChoices,
required=False,
widget=StaticSelect2Multiple()
)
tag = TagFilterField(model)
@@ -4494,7 +4564,6 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm):
class PowerPanelForm(BootstrapMixin, forms.ModelForm):
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False,
widget=APISelect(
api_url="/api/dcim/sites/",
filter_for={

View File

@@ -1,19 +0,0 @@
from django.db.models import Manager, QuerySet
from .constants import NONCONNECTABLE_IFACE_TYPES
class InterfaceQuerySet(QuerySet):
def connectable(self):
"""
Return only physical interfaces which are capable of being connected to other interfaces (i.e. not virtual or
wireless).
"""
return self.exclude(type__in=NONCONNECTABLE_IFACE_TYPES)
class InterfaceManager(Manager):
def get_queryset(self):
return InterfaceQuerySet(self.model, using=self._db)

View File

@@ -1,839 +0,0 @@
import sys
import django.core.validators
import django.db.models.deletion
import taggit.managers
from django.db import migrations, models
SITE_STATUS_CHOICES = (
(1, 'active'),
(2, 'planned'),
(4, 'retired'),
)
RACK_TYPE_CHOICES = (
(100, '2-post-frame'),
(200, '4-post-frame'),
(300, '4-post-cabinet'),
(1000, 'wall-frame'),
(1100, 'wall-cabinet'),
)
RACK_STATUS_CHOICES = (
(0, 'reserved'),
(1, 'available'),
(2, 'planned'),
(3, 'active'),
(4, 'deprecated'),
)
RACK_DIMENSION_CHOICES = (
(1000, 'mm'),
(2000, 'in'),
)
SUBDEVICE_ROLE_CHOICES = (
('true', 'parent'),
('false', 'child'),
)
DEVICE_FACE_CHOICES = (
(0, 'front'),
(1, 'rear'),
)
DEVICE_STATUS_CHOICES = (
(0, 'offline'),
(1, 'active'),
(2, 'planned'),
(3, 'staged'),
(4, 'failed'),
(5, 'inventory'),
(6, 'decommissioning'),
)
INTERFACE_TYPE_CHOICES = (
(0, 'virtual'),
(200, 'lag'),
(800, '100base-tx'),
(1000, '1000base-t'),
(1050, '1000base-x-gbic'),
(1100, '1000base-x-sfp'),
(1120, '2.5gbase-t'),
(1130, '5gbase-t'),
(1150, '10gbase-t'),
(1170, '10gbase-cx4'),
(1200, '10gbase-x-sfpp'),
(1300, '10gbase-x-xfp'),
(1310, '10gbase-x-xenpak'),
(1320, '10gbase-x-x2'),
(1350, '25gbase-x-sfp28'),
(1400, '40gbase-x-qsfpp'),
(1420, '50gbase-x-sfp28'),
(1500, '100gbase-x-cfp'),
(1510, '100gbase-x-cfp2'),
(1520, '100gbase-x-cfp4'),
(1550, '100gbase-x-cpak'),
(1600, '100gbase-x-qsfp28'),
(1650, '200gbase-x-cfp2'),
(1700, '200gbase-x-qsfp56'),
(1750, '400gbase-x-qsfpdd'),
(1800, '400gbase-x-osfp'),
(2600, 'ieee802.11a'),
(2610, 'ieee802.11g'),
(2620, 'ieee802.11n'),
(2630, 'ieee802.11ac'),
(2640, 'ieee802.11ad'),
(2810, 'gsm'),
(2820, 'cdma'),
(2830, 'lte'),
(6100, 'sonet-oc3'),
(6200, 'sonet-oc12'),
(6300, 'sonet-oc48'),
(6400, 'sonet-oc192'),
(6500, 'sonet-oc768'),
(6600, 'sonet-oc1920'),
(6700, 'sonet-oc3840'),
(3010, '1gfc-sfp'),
(3020, '2gfc-sfp'),
(3040, '4gfc-sfp'),
(3080, '8gfc-sfpp'),
(3160, '16gfc-sfpp'),
(3320, '32gfc-sfp28'),
(3400, '128gfc-sfp28'),
(7010, 'inifiband-sdr'),
(7020, 'inifiband-ddr'),
(7030, 'inifiband-qdr'),
(7040, 'inifiband-fdr10'),
(7050, 'inifiband-fdr'),
(7060, 'inifiband-edr'),
(7070, 'inifiband-hdr'),
(7080, 'inifiband-ndr'),
(7090, 'inifiband-xdr'),
(4000, 't1'),
(4010, 'e1'),
(4040, 't3'),
(4050, 'e3'),
(5000, 'cisco-stackwise'),
(5050, 'cisco-stackwise-plus'),
(5100, 'cisco-flexstack'),
(5150, 'cisco-flexstack-plus'),
(5200, 'juniper-vcp'),
(5300, 'extreme-summitstack'),
(5310, 'extreme-summitstack-128'),
(5320, 'extreme-summitstack-256'),
(5330, 'extreme-summitstack-512'),
)
INTERFACE_MODE_CHOICES = (
(100, 'access'),
(200, 'tagged'),
(300, 'tagged-all'),
)
PORT_TYPE_CHOICES = (
(1000, '8p8c'),
(1100, '110-punch'),
(1200, 'bnc'),
(2000, 'st'),
(2100, 'sc'),
(2110, 'sc-apc'),
(2200, 'fc'),
(2300, 'lc'),
(2310, 'lc-apc'),
(2400, 'mtrj'),
(2500, 'mpo'),
(2600, 'lsh'),
(2610, 'lsh-apc'),
)
CABLE_TYPE_CHOICES = (
(1300, 'cat3'),
(1500, 'cat5'),
(1510, 'cat5e'),
(1600, 'cat6'),
(1610, 'cat6a'),
(1700, 'cat7'),
(1800, 'dac-active'),
(1810, 'dac-passive'),
(1900, 'coaxial'),
(3000, 'mmf'),
(3010, 'mmf-om1'),
(3020, 'mmf-om2'),
(3030, 'mmf-om3'),
(3040, 'mmf-om4'),
(3500, 'smf'),
(3510, 'smf-os1'),
(3520, 'smf-os2'),
(3800, 'aoc'),
(5000, 'power'),
)
CABLE_STATUS_CHOICES = (
('true', 'connected'),
('false', 'planned'),
)
CABLE_LENGTH_UNIT_CHOICES = (
(1200, 'm'),
(1100, 'cm'),
(2100, 'ft'),
(2000, 'in'),
)
POWERFEED_STATUS_CHOICES = (
(0, 'offline'),
(1, 'active'),
(2, 'planned'),
(4, 'failed'),
)
POWERFEED_TYPE_CHOICES = (
(1, 'primary'),
(2, 'redundant'),
)
POWERFEED_SUPPLY_CHOICES = (
(1, 'ac'),
(2, 'dc'),
)
POWERFEED_PHASE_CHOICES = (
(1, 'single-phase'),
(3, 'three-phase'),
)
POWEROUTLET_FEED_LEG_CHOICES_CHOICES = (
(1, 'A'),
(2, 'B'),
(3, 'C'),
)
def cache_cable_devices(apps, schema_editor):
Cable = apps.get_model('dcim', 'Cable')
if 'test' not in sys.argv:
print("\nUpdating cable device terminations...")
cable_count = Cable.objects.count()
# Cache A/B termination devices on all existing Cables. Note that the custom save() method on Cable is not
# available during a migration, so we replicate its logic here.
for i, cable in enumerate(Cable.objects.all(), start=1):
if not i % 1000 and 'test' not in sys.argv:
print("[{}/{}]".format(i, cable_count))
termination_a_model = apps.get_model(cable.termination_a_type.app_label, cable.termination_a_type.model)
termination_a_device = None
if hasattr(termination_a_model, 'device'):
termination_a = termination_a_model.objects.get(pk=cable.termination_a_id)
termination_a_device = termination_a.device
termination_b_model = apps.get_model(cable.termination_b_type.app_label, cable.termination_b_type.model)
termination_b_device = None
if hasattr(termination_b_model, 'device'):
termination_b = termination_b_model.objects.get(pk=cable.termination_b_id)
termination_b_device = termination_b.device
Cable.objects.filter(pk=cable.pk).update(
_termination_a_device=termination_a_device,
_termination_b_device=termination_b_device
)
def site_status_to_slug(apps, schema_editor):
Site = apps.get_model('dcim', 'Site')
for id, slug in SITE_STATUS_CHOICES:
Site.objects.filter(status=str(id)).update(status=slug)
def rack_type_to_slug(apps, schema_editor):
Rack = apps.get_model('dcim', 'Rack')
for id, slug in RACK_TYPE_CHOICES:
Rack.objects.filter(type=str(id)).update(type=slug)
def rack_status_to_slug(apps, schema_editor):
Rack = apps.get_model('dcim', 'Rack')
for id, slug in RACK_STATUS_CHOICES:
Rack.objects.filter(status=str(id)).update(status=slug)
def rack_outer_unit_to_slug(apps, schema_editor):
Rack = apps.get_model('dcim', 'Rack')
for id, slug in RACK_DIMENSION_CHOICES:
Rack.objects.filter(status=str(id)).update(status=slug)
def devicetype_subdevicerole_to_slug(apps, schema_editor):
DeviceType = apps.get_model('dcim', 'DeviceType')
for boolean, slug in SUBDEVICE_ROLE_CHOICES:
DeviceType.objects.filter(subdevice_role=boolean).update(subdevice_role=slug)
def device_face_to_slug(apps, schema_editor):
Device = apps.get_model('dcim', 'Device')
for id, slug in DEVICE_FACE_CHOICES:
Device.objects.filter(face=str(id)).update(face=slug)
def device_status_to_slug(apps, schema_editor):
Device = apps.get_model('dcim', 'Device')
for id, slug in DEVICE_STATUS_CHOICES:
Device.objects.filter(status=str(id)).update(status=slug)
def interfacetemplate_type_to_slug(apps, schema_editor):
InterfaceTemplate = apps.get_model('dcim', 'InterfaceTemplate')
for id, slug in INTERFACE_TYPE_CHOICES:
InterfaceTemplate.objects.filter(type=id).update(type=slug)
def interface_type_to_slug(apps, schema_editor):
Interface = apps.get_model('dcim', 'Interface')
for id, slug in INTERFACE_TYPE_CHOICES:
Interface.objects.filter(type=id).update(type=slug)
def interface_mode_to_slug(apps, schema_editor):
Interface = apps.get_model('dcim', 'Interface')
for id, slug in INTERFACE_MODE_CHOICES:
Interface.objects.filter(mode=id).update(mode=slug)
def frontporttemplate_type_to_slug(apps, schema_editor):
FrontPortTemplate = apps.get_model('dcim', 'FrontPortTemplate')
for id, slug in PORT_TYPE_CHOICES:
FrontPortTemplate.objects.filter(type=id).update(type=slug)
def rearporttemplate_type_to_slug(apps, schema_editor):
RearPortTemplate = apps.get_model('dcim', 'RearPortTemplate')
for id, slug in PORT_TYPE_CHOICES:
RearPortTemplate.objects.filter(type=id).update(type=slug)
def frontport_type_to_slug(apps, schema_editor):
FrontPort = apps.get_model('dcim', 'FrontPort')
for id, slug in PORT_TYPE_CHOICES:
FrontPort.objects.filter(type=id).update(type=slug)
def rearport_type_to_slug(apps, schema_editor):
RearPort = apps.get_model('dcim', 'RearPort')
for id, slug in PORT_TYPE_CHOICES:
RearPort.objects.filter(type=id).update(type=slug)
def cable_type_to_slug(apps, schema_editor):
Cable = apps.get_model('dcim', 'Cable')
for id, slug in CABLE_TYPE_CHOICES:
Cable.objects.filter(type=id).update(type=slug)
def cable_status_to_slug(apps, schema_editor):
Cable = apps.get_model('dcim', 'Cable')
for bool_str, slug in CABLE_STATUS_CHOICES:
Cable.objects.filter(status=bool_str).update(status=slug)
def cable_length_unit_to_slug(apps, schema_editor):
Cable = apps.get_model('dcim', 'Cable')
for id, slug in CABLE_LENGTH_UNIT_CHOICES:
Cable.objects.filter(length_unit=id).update(length_unit=slug)
def powerfeed_status_to_slug(apps, schema_editor):
PowerFeed = apps.get_model('dcim', 'PowerFeed')
for id, slug in POWERFEED_STATUS_CHOICES:
PowerFeed.objects.filter(status=id).update(status=slug)
def powerfeed_type_to_slug(apps, schema_editor):
PowerFeed = apps.get_model('dcim', 'PowerFeed')
for id, slug in POWERFEED_TYPE_CHOICES:
PowerFeed.objects.filter(type=id).update(type=slug)
def powerfeed_supply_to_slug(apps, schema_editor):
PowerFeed = apps.get_model('dcim', 'PowerFeed')
for id, slug in POWERFEED_SUPPLY_CHOICES:
PowerFeed.objects.filter(supply=id).update(supply=slug)
def powerfeed_phase_to_slug(apps, schema_editor):
PowerFeed = apps.get_model('dcim', 'PowerFeed')
for id, slug in POWERFEED_PHASE_CHOICES:
PowerFeed.objects.filter(phase=id).update(phase=slug)
def poweroutlettemplate_feed_leg_to_slug(apps, schema_editor):
PowerOutletTemplate = apps.get_model('dcim', 'PowerOutletTemplate')
for id, slug in POWEROUTLET_FEED_LEG_CHOICES_CHOICES:
PowerOutletTemplate.objects.filter(feed_leg=id).update(feed_leg=slug)
def poweroutlet_feed_leg_to_slug(apps, schema_editor):
PowerOutlet = apps.get_model('dcim', 'PowerOutlet')
for id, slug in POWEROUTLET_FEED_LEG_CHOICES_CHOICES:
PowerOutlet.objects.filter(feed_leg=id).update(feed_leg=slug)
class Migration(migrations.Migration):
replaces = [('dcim', '0071_device_components_add_description'), ('dcim', '0072_powerfeeds'), ('dcim', '0073_interface_form_factor_to_type'), ('dcim', '0074_increase_field_length_platform_name_slug'), ('dcim', '0075_cable_devices'), ('dcim', '0076_console_port_types'), ('dcim', '0077_power_types'), ('dcim', '0078_3569_site_fields'), ('dcim', '0079_3569_rack_fields'), ('dcim', '0080_3569_devicetype_fields'), ('dcim', '0081_3569_device_fields'), ('dcim', '0082_3569_interface_fields'), ('dcim', '0082_3569_port_fields'), ('dcim', '0083_3569_cable_fields'), ('dcim', '0084_3569_powerfeed_fields'), ('dcim', '0085_3569_poweroutlet_fields'), ('dcim', '0086_device_name_nonunique'), ('dcim', '0087_role_descriptions'), ('dcim', '0088_powerfeed_available_power')]
dependencies = [
('dcim', '0070_custom_tag_models'),
('extras', '0021_add_color_comments_changelog_to_tag'),
('tenancy', '0006_custom_tag_models'),
]
operations = [
migrations.AddField(
model_name='consoleport',
name='description',
field=models.CharField(blank=True, max_length=100),
),
migrations.AddField(
model_name='consoleserverport',
name='description',
field=models.CharField(blank=True, max_length=100),
),
migrations.AddField(
model_name='devicebay',
name='description',
field=models.CharField(blank=True, max_length=100),
),
migrations.AddField(
model_name='poweroutlet',
name='description',
field=models.CharField(blank=True, max_length=100),
),
migrations.AddField(
model_name='powerport',
name='description',
field=models.CharField(blank=True, max_length=100),
),
migrations.CreateModel(
name='PowerPanel',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
('name', models.CharField(max_length=50)),
('rack_group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='dcim.RackGroup')),
('site', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='dcim.Site')),
],
options={
'ordering': ['site', 'name'],
'unique_together': {('site', 'name')},
},
),
migrations.CreateModel(
name='PowerFeed',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
('name', models.CharField(max_length=50)),
('status', models.PositiveSmallIntegerField(default=1)),
('type', models.PositiveSmallIntegerField(default=1)),
('supply', models.PositiveSmallIntegerField(default=1)),
('phase', models.PositiveSmallIntegerField(default=1)),
('voltage', models.PositiveSmallIntegerField(default=120, validators=[django.core.validators.MinValueValidator(1)])),
('amperage', models.PositiveSmallIntegerField(default=20, validators=[django.core.validators.MinValueValidator(1)])),
('max_utilization', models.PositiveSmallIntegerField(default=80, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)])),
('available_power', models.PositiveSmallIntegerField(default=0, editable=False)),
('comments', models.TextField(blank=True)),
('cable', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.Cable')),
('power_panel', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='powerfeeds', to='dcim.PowerPanel')),
('rack', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='dcim.Rack')),
('tags', taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='extras.TaggedItem', to='extras.Tag', verbose_name='Tags')),
('connected_endpoint', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.PowerPort')),
('connection_status', models.NullBooleanField()),
],
options={
'ordering': ['power_panel', 'name'],
'unique_together': {('power_panel', 'name')},
},
),
migrations.RenameField(
model_name='powerport',
old_name='connected_endpoint',
new_name='_connected_poweroutlet',
),
migrations.AddField(
model_name='powerport',
name='_connected_powerfeed',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.PowerFeed'),
),
migrations.AddField(
model_name='powerport',
name='allocated_draw',
field=models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]),
),
migrations.AddField(
model_name='powerport',
name='maximum_draw',
field=models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]),
),
migrations.AddField(
model_name='powerporttemplate',
name='allocated_draw',
field=models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]),
),
migrations.AddField(
model_name='powerporttemplate',
name='maximum_draw',
field=models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]),
),
migrations.AddField(
model_name='poweroutlet',
name='feed_leg',
field=models.PositiveSmallIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='poweroutlet',
name='power_port',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='poweroutlets', to='dcim.PowerPort'),
),
migrations.AddField(
model_name='poweroutlettemplate',
name='feed_leg',
field=models.PositiveSmallIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='poweroutlettemplate',
name='power_port',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='poweroutlet_templates', to='dcim.PowerPortTemplate'),
),
migrations.RenameField(
model_name='interface',
old_name='form_factor',
new_name='type',
),
migrations.RenameField(
model_name='interfacetemplate',
old_name='form_factor',
new_name='type',
),
migrations.AlterField(
model_name='platform',
name='name',
field=models.CharField(max_length=100, unique=True),
),
migrations.AlterField(
model_name='platform',
name='slug',
field=models.SlugField(max_length=100, unique=True),
),
migrations.AddField(
model_name='cable',
name='_termination_a_device',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='dcim.Device'),
),
migrations.AddField(
model_name='cable',
name='_termination_b_device',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='dcim.Device'),
),
migrations.RunPython(
code=cache_cable_devices,
reverse_code=django.db.migrations.operations.special.RunPython.noop,
),
migrations.AddField(
model_name='consoleport',
name='type',
field=models.CharField(blank=True, max_length=50),
),
migrations.AddField(
model_name='consoleporttemplate',
name='type',
field=models.CharField(blank=True, max_length=50),
),
migrations.AddField(
model_name='consoleserverport',
name='type',
field=models.CharField(blank=True, max_length=50),
),
migrations.AddField(
model_name='consoleserverporttemplate',
name='type',
field=models.CharField(blank=True, max_length=50),
),
migrations.AddField(
model_name='poweroutlet',
name='type',
field=models.CharField(blank=True, max_length=50),
),
migrations.AddField(
model_name='poweroutlettemplate',
name='type',
field=models.CharField(blank=True, max_length=50),
),
migrations.AddField(
model_name='powerport',
name='type',
field=models.CharField(blank=True, max_length=50),
),
migrations.AddField(
model_name='powerporttemplate',
name='type',
field=models.CharField(blank=True, max_length=50),
),
migrations.AlterField(
model_name='site',
name='status',
field=models.CharField(default='active', max_length=50),
),
migrations.RunPython(
code=site_status_to_slug,
),
migrations.AlterField(
model_name='rack',
name='type',
field=models.CharField(blank=True, default='', max_length=50),
),
migrations.RunPython(
code=rack_type_to_slug,
),
migrations.AlterField(
model_name='rack',
name='type',
field=models.CharField(blank=True, max_length=50),
),
migrations.AlterField(
model_name='rack',
name='status',
field=models.CharField(default='active', max_length=50),
),
migrations.RunPython(
code=rack_status_to_slug,
),
migrations.AlterField(
model_name='rack',
name='outer_unit',
field=models.CharField(blank=True, default='', max_length=50),
),
migrations.RunPython(
code=rack_outer_unit_to_slug,
),
migrations.AlterField(
model_name='rack',
name='outer_unit',
field=models.CharField(blank=True, max_length=50),
),
migrations.AlterField(
model_name='devicetype',
name='subdevice_role',
field=models.CharField(blank=True, default='', max_length=50),
),
migrations.RunPython(
code=devicetype_subdevicerole_to_slug,
),
migrations.AlterField(
model_name='devicetype',
name='subdevice_role',
field=models.CharField(blank=True, max_length=50),
),
migrations.AlterField(
model_name='device',
name='face',
field=models.CharField(blank=True, default='', max_length=50),
),
migrations.RunPython(
code=device_face_to_slug,
),
migrations.AlterField(
model_name='device',
name='face',
field=models.CharField(blank=True, max_length=50),
),
migrations.AlterField(
model_name='device',
name='status',
field=models.CharField(default='active', max_length=50),
),
migrations.RunPython(
code=device_status_to_slug,
),
migrations.AlterField(
model_name='interfacetemplate',
name='type',
field=models.CharField(max_length=50),
),
migrations.RunPython(
code=interfacetemplate_type_to_slug,
),
migrations.AlterField(
model_name='interface',
name='type',
field=models.CharField(max_length=50),
),
migrations.RunPython(
code=interface_type_to_slug,
),
migrations.AlterField(
model_name='interface',
name='mode',
field=models.CharField(blank=True, default='', max_length=50),
),
migrations.RunPython(
code=interface_mode_to_slug,
),
migrations.AlterField(
model_name='interface',
name='mode',
field=models.CharField(blank=True, max_length=50),
),
migrations.AlterField(
model_name='frontporttemplate',
name='type',
field=models.CharField(max_length=50),
),
migrations.RunPython(
code=frontporttemplate_type_to_slug,
),
migrations.AlterField(
model_name='rearporttemplate',
name='type',
field=models.CharField(max_length=50),
),
migrations.RunPython(
code=rearporttemplate_type_to_slug,
),
migrations.AlterField(
model_name='frontport',
name='type',
field=models.CharField(max_length=50),
),
migrations.RunPython(
code=frontport_type_to_slug,
),
migrations.AlterField(
model_name='rearport',
name='type',
field=models.CharField(max_length=50),
),
migrations.RunPython(
code=rearport_type_to_slug,
),
migrations.AlterField(
model_name='cable',
name='type',
field=models.CharField(blank=True, default='', max_length=50),
),
migrations.RunPython(
code=cable_type_to_slug,
),
migrations.AlterField(
model_name='cable',
name='type',
field=models.CharField(blank=True, max_length=50),
),
migrations.AlterField(
model_name='cable',
name='status',
field=models.CharField(default='connected', max_length=50),
),
migrations.RunPython(
code=cable_status_to_slug,
),
migrations.AlterField(
model_name='cable',
name='length_unit',
field=models.CharField(blank=True, default='', max_length=50),
),
migrations.RunPython(
code=cable_length_unit_to_slug,
),
migrations.AlterField(
model_name='cable',
name='length_unit',
field=models.CharField(blank=True, max_length=50),
),
migrations.AlterField(
model_name='powerfeed',
name='status',
field=models.CharField(default='active', max_length=50),
),
migrations.RunPython(
code=powerfeed_status_to_slug,
),
migrations.AlterField(
model_name='powerfeed',
name='type',
field=models.CharField(default='primary', max_length=50),
),
migrations.RunPython(
code=powerfeed_type_to_slug,
),
migrations.AlterField(
model_name='powerfeed',
name='supply',
field=models.CharField(default='ac', max_length=50),
),
migrations.RunPython(
code=powerfeed_supply_to_slug,
),
migrations.AlterField(
model_name='powerfeed',
name='phase',
field=models.CharField(default='single-phase', max_length=50),
),
migrations.RunPython(
code=powerfeed_phase_to_slug,
),
migrations.AlterField(
model_name='poweroutlettemplate',
name='feed_leg',
field=models.CharField(blank=True, default='', max_length=50),
),
migrations.RunPython(
code=poweroutlettemplate_feed_leg_to_slug,
),
migrations.AlterField(
model_name='poweroutlettemplate',
name='feed_leg',
field=models.CharField(blank=True, max_length=50),
),
migrations.AlterField(
model_name='poweroutlet',
name='feed_leg',
field=models.CharField(blank=True, default='', max_length=50),
),
migrations.RunPython(
code=poweroutlet_feed_leg_to_slug,
),
migrations.AlterField(
model_name='poweroutlet',
name='feed_leg',
field=models.CharField(blank=True, max_length=50),
),
migrations.AlterField(
model_name='device',
name='name',
field=models.CharField(blank=True, max_length=64, null=True),
),
migrations.AlterUniqueTogether(
name='device',
unique_together={('rack', 'position', 'face'), ('site', 'tenant', 'name'), ('virtual_chassis', 'vc_position')},
),
migrations.AddField(
model_name='devicerole',
name='description',
field=models.CharField(blank=True, max_length=100),
),
migrations.AddField(
model_name='rackrole',
name='description',
field=models.CharField(blank=True, max_length=100),
),
migrations.AlterField(
model_name='powerfeed',
name='available_power',
field=models.PositiveIntegerField(default=0, editable=False),
),
]

View File

@@ -0,0 +1,20 @@
from django.db import migrations
def interfacetemplate_type_to_slug(apps, schema_editor):
InterfaceTemplate = apps.get_model('dcim', 'InterfaceTemplate')
InterfaceTemplate.objects.filter(type=32767).update(type='other')
class Migration(migrations.Migration):
dependencies = [
('dcim', '0096_interface_ordering'),
]
operations = [
# Missed type "other" in the initial migration (see #3967)
migrations.RunPython(
code=interfacetemplate_type_to_slug
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 2.2.9 on 2020-02-20 15:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0097_interfacetemplate_type_other'),
]
operations = [
migrations.AddField(
model_name='devicetype',
name='front_image',
field=models.ImageField(blank=True, upload_to='devicetype-images'),
),
migrations.AddField(
model_name='devicetype',
name='rear_image',
field=models.ImageField(blank=True, upload_to='devicetype-images'),
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 2.2.10 on 2020-03-03 16:59
from django.db import migrations, models
import utilities.validators
class Migration(migrations.Migration):
dependencies = [
('dcim', '0098_devicetype_images'),
]
operations = [
migrations.AlterField(
model_name='powerfeed',
name='voltage',
field=models.SmallIntegerField(default=120, validators=[utilities.validators.ExclusionValidator([0])]),
),
]

View File

@@ -1,7 +1,6 @@
from collections import OrderedDict
from itertools import count, groupby
import svgwrite
import yaml
from django.conf import settings
from django.contrib.auth.models import User
@@ -13,7 +12,6 @@ from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import Count, F, ProtectedError, Sum
from django.urls import reverse
from django.utils.http import urlencode
from mptt.models import MPTTModel, TreeForeignKey
from taggit.managers import TaggableManager
from timezone_field import TimeZoneField
@@ -21,10 +19,12 @@ from timezone_field import TimeZoneField
from dcim.choices import *
from dcim.constants import *
from dcim.fields import ASNField
from extras.models import ConfigContextModel, CustomFieldModel, TaggedItem
from dcim.elevations import RackElevationSVG
from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange, TaggedItem
from utilities.fields import ColorField, NaturalOrderingField
from utilities.models import ChangeLoggedModel
from utilities.utils import foreground_color, to_meters
from utilities.utils import serialize_object, to_meters
from utilities.validators import ExclusionValidator
from .device_component_templates import (
ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, FrontPortTemplate, InterfaceTemplate,
PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
@@ -119,6 +119,15 @@ class Region(MPTTModel, ChangeLoggedModel):
Q(region__in=self.get_descendants())
).count()
def to_objectchange(self, action):
# Remove MPTT-internal fields
return ObjectChange(
changed_object=self,
object_repr=str(self),
action=action,
object_data=serialize_object(self, exclude=['level', 'lft', 'rght', 'tree_id'])
)
#
# Sites
@@ -350,180 +359,7 @@ class RackRole(ChangeLoggedModel):
)
class RackElevationHelperMixin:
"""
Utility class that renders rack elevations. Contains helper methods for rendering elevations as a list of
rack units represented as dictionaries, or an SVG of the elevation.
"""
@staticmethod
def _add_gradient(drawing, id_, color):
gradient = drawing.linearGradient(
start=('0', '0%'),
end=('0', '5%'),
spreadMethod='repeat',
id_=id_,
gradientTransform='rotate(45, 0, 0)',
gradientUnits='userSpaceOnUse'
)
gradient.add_stop_color(offset='0%', color='#f7f7f7')
gradient.add_stop_color(offset='50%', color='#f7f7f7')
gradient.add_stop_color(offset='50%', color=color)
gradient.add_stop_color(offset='100%', color=color)
drawing.defs.add(gradient)
@staticmethod
def _setup_drawing(width, height):
drawing = svgwrite.Drawing(size=(width, height))
# add the stylesheet
with open('{}/css/rack_elevation.css'.format(settings.STATICFILES_DIRS[0])) as css_file:
drawing.defs.add(drawing.style(css_file.read()))
# add gradients
RackElevationHelperMixin._add_gradient(drawing, 'reserved', '#c7c7ff')
RackElevationHelperMixin._add_gradient(drawing, 'occupied', '#f0f0f0')
RackElevationHelperMixin._add_gradient(drawing, 'blocked', '#ffc7c7')
return drawing
@staticmethod
def _draw_device_front(drawing, device, start, end, text):
name = str(device)
if device.devicebay_count:
name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count)
color = device.device_role.color
link = drawing.add(
drawing.a(
href=reverse('dcim:device', kwargs={'pk': device.pk}),
target='_top',
fill='black'
)
)
link.set_desc('{}{} ({}U) {} {}'.format(
device.device_role, device.device_type.display_name,
device.device_type.u_height, device.asset_tag or '', device.serial or ''
))
link.add(drawing.rect(start, end, style='fill: #{}'.format(color), class_='slot'))
hex_color = '#{}'.format(foreground_color(color))
link.add(drawing.text(str(name), insert=text, fill=hex_color))
@staticmethod
def _draw_device_rear(drawing, device, start, end, text):
rect = drawing.rect(start, end, class_="slot blocked")
rect.set_desc('{}{} ({}U) {} {}'.format(
device.device_role, device.device_type.display_name,
device.device_type.u_height, device.asset_tag or '', device.serial or ''
))
drawing.add(rect)
drawing.add(drawing.text(str(device), insert=text))
@staticmethod
def _draw_empty(drawing, rack, start, end, text, id_, face_id, class_, reservation):
link = drawing.add(
drawing.a(
href='{}?{}'.format(
reverse('dcim:device_add'),
urlencode({'rack': rack.pk, 'site': rack.site.pk, 'face': face_id, 'position': id_})
),
target='_top'
)
)
if reservation:
link.set_desc('{}{} · {}'.format(
reservation.description, reservation.user, reservation.created
))
link.add(drawing.rect(start, end, class_=class_))
link.add(drawing.text("add device", insert=text, class_='add-device'))
def _draw_elevations(self, elevation, reserved_units, face, unit_width, unit_height, legend_width):
drawing = self._setup_drawing(unit_width + legend_width, unit_height * self.u_height)
unit_cursor = 0
for ru in range(0, self.u_height):
start_y = ru * unit_height
position_coordinates = (legend_width / 2, start_y + unit_height / 2 + 2)
unit = ru + 1 if self.desc_units else self.u_height - ru
drawing.add(
drawing.text(str(unit), position_coordinates, class_="unit")
)
for unit in elevation:
# Loop through all units in the elevation
device = unit['device']
height = unit.get('height', 1)
# Setup drawing coordinates
start_y = unit_cursor * unit_height
end_y = unit_height * height
start_cordinates = (legend_width, start_y)
end_cordinates = (legend_width + unit_width, end_y)
text_cordinates = (legend_width + (unit_width / 2), start_y + end_y / 2)
# Draw the device
if device and device.face == face:
self._draw_device_front(drawing, device, start_cordinates, end_cordinates, text_cordinates)
elif device and device.device_type.is_full_depth:
self._draw_device_rear(drawing, device, start_cordinates, end_cordinates, text_cordinates)
else:
# Draw shallow devices, reservations, or empty units
class_ = 'slot'
reservation = reserved_units.get(unit["id"])
if device:
class_ += ' occupied'
if reservation:
class_ += ' reserved'
self._draw_empty(
drawing, self, start_cordinates, end_cordinates, text_cordinates, unit["id"], face, class_, reservation
)
unit_cursor += height
# Wrap the drawing with a border
drawing.add(drawing.rect((legend_width, 0), (unit_width, self.u_height * unit_height), class_='rack'))
return drawing
def merge_elevations(self, face):
elevation = self.get_rack_units(face=face, expand_devices=False)
other_face = DeviceFaceChoices.FACE_FRONT if face == DeviceFaceChoices.FACE_REAR else DeviceFaceChoices.FACE_REAR
other = self.get_rack_units(face=other_face)
unit_cursor = 0
for u in elevation:
o = other[unit_cursor]
if not u['device'] and o['device']:
u['device'] = o['device']
u['height'] = 1
unit_cursor += u.get('height', 1)
return elevation
def get_elevation_svg(
self,
face=DeviceFaceChoices.FACE_FRONT,
unit_width=RACK_ELEVATION_UNIT_WIDTH_DEFAULT,
unit_height=RACK_ELEVATION_UNIT_HEIGHT_DEFAULT,
legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT
):
"""
Return an SVG of the rack elevation
:param face: Enum of [front, rear] representing the desired side of the rack elevation to render
:param width: Width in pixles for the rendered drawing
:param unit_height: Height of each rack unit for the rendered drawing. Note this is not the total
height of the elevation
"""
elevation = self.merge_elevations(face)
reserved_units = self.get_reserved_units()
return self._draw_elevations(elevation, reserved_units, face, unit_width, unit_height, legend_width)
class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin):
class Rack(ChangeLoggedModel, CustomFieldModel):
"""
Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
Each Rack is assigned to a Site and (optionally) a RackGroup.
@@ -835,6 +671,28 @@ class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin):
reserved_units[u] = r
return reserved_units
def get_elevation_svg(
self,
face=DeviceFaceChoices.FACE_FRONT,
unit_width=RACK_ELEVATION_UNIT_WIDTH_DEFAULT,
unit_height=RACK_ELEVATION_UNIT_HEIGHT_DEFAULT,
legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT,
include_images=True
):
"""
Return an SVG of the rack elevation
:param face: Enum of [front, rear] representing the desired side of the rack elevation to render
:param unit_width: Width in pixels for the rendered drawing
:param unit_height: Height of each rack unit for the rendered drawing. Note this is not the total
height of the elevation
:param legend_width: Width of the unit legend, in pixels
:param include_images: Embed front/rear device images where available
"""
elevation = RackElevationSVG(self, include_images=include_images)
return elevation.render(face, unit_width, unit_height, legend_width)
def get_0u_devices(self):
return self.devices.filter(position=0)
@@ -1025,6 +883,14 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
help_text='Parent devices house child devices in device bays. Leave blank '
'if this device type is neither a parent nor a child.'
)
front_image = models.ImageField(
upload_to='devicetype-images',
blank=True
)
rear_image = models.ImageField(
upload_to='devicetype-images',
blank=True
)
comments = models.TextField(
blank=True
)
@@ -1056,6 +922,10 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
# Save a copy of u_height for validation in clean()
self._original_u_height = self.u_height
# Save references to the original front/rear images
self._original_front_image = self.front_image
self._original_rear_image = self.rear_image
def get_absolute_url(self):
return reverse('dcim:devicetype', args=[self.pk])
@@ -1175,6 +1045,26 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
'u_height': "Child device types must be 0U."
})
def save(self, *args, **kwargs):
ret = super().save(*args, **kwargs)
# Delete any previously uploaded image files that are no longer in use
if self.front_image != self._original_front_image:
self._original_front_image.delete(save=False)
if self.rear_image != self._original_rear_image:
self._original_rear_image.delete(save=False)
return ret
def delete(self, *args, **kwargs):
super().delete(*args, **kwargs)
# Delete any uploaded image files
if self.front_image:
self.front_image.delete(save=False)
if self.rear_image:
self.rear_image.delete(save=False)
@property
def display_name(self):
return '{} {}'.format(self.manufacturer.name, self.model)
@@ -1886,9 +1776,9 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
choices=PowerFeedPhaseChoices,
default=PowerFeedPhaseChoices.PHASE_SINGLE
)
voltage = models.PositiveSmallIntegerField(
validators=[MinValueValidator(1)],
default=POWERFEED_VOLTAGE_DEFAULT
voltage = models.SmallIntegerField(
default=POWERFEED_VOLTAGE_DEFAULT,
validators=[ExclusionValidator([0])]
)
amperage = models.PositiveSmallIntegerField(
validators=[MinValueValidator(1)],
@@ -1970,10 +1860,16 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
self.rack, self.rack.site, self.power_panel, self.power_panel.site
))
# AC voltage cannot be negative
if self.voltage < 0 and self.supply == PowerFeedSupplyChoices.SUPPLY_AC:
raise ValidationError({
"voltage": "Voltage cannot be negative for AC supply"
})
def save(self, *args, **kwargs):
# Cache the available_power property on the instance
kva = self.voltage * self.amperage * (self.max_utilization / 100)
kva = abs(self.voltage) * self.amperage * (self.max_utilization / 100)
if self.phase == PowerFeedPhaseChoices.PHASE_3PHASE:
self.available_power = round(kva * 1.732)
else:
@@ -2076,6 +1972,7 @@ class Cable(ChangeLoggedModel):
STATUS_CLASS_MAP = {
CableStatusChoices.STATUS_CONNECTED: 'success',
CableStatusChoices.STATUS_PLANNED: 'info',
CableStatusChoices.STATUS_DECOMMISSIONING: 'warning',
}
class Meta:
@@ -2236,14 +2133,14 @@ class Cable(ChangeLoggedModel):
b_path = self.termination_a.trace()
# Determine overall path status (connected or planned)
if self.status == CableStatusChoices.STATUS_PLANNED:
path_status = CONNECTION_STATUS_PLANNED
else:
path_status = CONNECTION_STATUS_CONNECTED
if self.status == CableStatusChoices.STATUS_CONNECTED:
path_status = True
for segment in a_path[1:] + b_path[1:]:
if segment[1] is None or segment[1].status == CableStatusChoices.STATUS_PLANNED:
path_status = CONNECTION_STATUS_PLANNED
if segment[1] is None or segment[1].status != CableStatusChoices.STATUS_CONNECTED:
path_status = False
break
else:
path_status = False
a_endpoint = a_path[-1][2]
b_endpoint = b_path[-1][2]

View File

@@ -1,4 +1,4 @@
from django.core.exceptions import ValidationError
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
@@ -37,11 +37,17 @@ class ComponentTemplateModel(models.Model):
raise NotImplementedError()
def to_objectchange(self, action):
# Annotate the parent DeviceType
try:
device_type = self.device_type
except ObjectDoesNotExist:
# The parent DeviceType has already been deleted
device_type = None
return ObjectChange(
changed_object=self,
object_repr=str(self),
action=action,
related_object=self.device_type,
related_object=device_type,
object_data=serialize_object(self)
)

View File

@@ -360,9 +360,21 @@ class PowerPort(CableTermination, ComponentModel):
@property
def connected_endpoint(self):
if self._connected_poweroutlet:
return self._connected_poweroutlet
return self._connected_powerfeed
"""
Return the connected PowerOutlet, if it exists, or the connected PowerFeed, if it exists. We have to check for
ObjectDoesNotExist in case the referenced object has been deleted from the database.
"""
try:
if self._connected_poweroutlet:
return self._connected_poweroutlet
except ObjectDoesNotExist:
pass
try:
if self._connected_powerfeed:
return self._connected_powerfeed
except ObjectDoesNotExist:
pass
return None
@connected_endpoint.setter
def connected_endpoint(self, value):
@@ -717,9 +729,21 @@ class Interface(CableTermination, ComponentModel):
@property
def connected_endpoint(self):
if self._connected_interface:
return self._connected_interface
return self._connected_circuittermination
"""
Return the connected Interface, if it exists, or the connected CircuitTermination, if it exists. We have to
check for ObjectDoesNotExist in case the referenced object has been deleted from the database.
"""
try:
if self._connected_interface:
return self._connected_interface
except ObjectDoesNotExist:
pass
try:
if self._connected_circuittermination:
return self._connected_circuittermination
except ObjectDoesNotExist:
pass
return None
@connected_endpoint.setter
def connected_endpoint(self, value):

View File

@@ -41,7 +41,7 @@ DEVICE_LINK = """
"""
REGION_ACTIONS = """
<a href="{% url 'dcim:region_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Changelog">
<a href="{% url 'dcim:region_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Change log">
<i class="fa fa-history"></i>
</a>
{% if perms.dcim.change_region %}
@@ -50,7 +50,7 @@ REGION_ACTIONS = """
"""
RACKGROUP_ACTIONS = """
<a href="{% url 'dcim:rackgroup_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Changelog">
<a href="{% url 'dcim:rackgroup_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Change log">
<i class="fa fa-history"></i>
</a>
<a href="{% url 'dcim:rack_elevation_list' %}?site={{ record.site.slug }}&group_id={{ record.pk }}" class="btn btn-xs btn-primary" title="View elevations">
@@ -64,7 +64,7 @@ RACKGROUP_ACTIONS = """
"""
RACKROLE_ACTIONS = """
<a href="{% url 'dcim:rackrole_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Changelog">
<a href="{% url 'dcim:rackrole_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Change log">
<i class="fa fa-history"></i>
</a>
{% if perms.dcim.change_rackrole %}
@@ -86,7 +86,7 @@ RACK_DEVICE_COUNT = """
"""
RACKRESERVATION_ACTIONS = """
<a href="{% url 'dcim:rackreservation_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Changelog">
<a href="{% url 'dcim:rackreservation_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Change log">
<i class="fa fa-history"></i>
</a>
{% if perms.dcim.change_rackreservation %}
@@ -95,7 +95,7 @@ RACKRESERVATION_ACTIONS = """
"""
MANUFACTURER_ACTIONS = """
<a href="{% url 'dcim:manufacturer_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
<a href="{% url 'dcim:manufacturer_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
<i class="fa fa-history"></i>
</a>
{% if perms.dcim.change_manufacturer %}
@@ -104,7 +104,7 @@ MANUFACTURER_ACTIONS = """
"""
DEVICEROLE_ACTIONS = """
<a href="{% url 'dcim:devicerole_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
<a href="{% url 'dcim:devicerole_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
<i class="fa fa-history"></i>
</a>
{% if perms.dcim.change_devicerole %}
@@ -129,7 +129,7 @@ PLATFORM_VM_COUNT = """
"""
PLATFORM_ACTIONS = """
<a href="{% url 'dcim:platform_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
<a href="{% url 'dcim:platform_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
<i class="fa fa-history"></i>
</a>
{% if perms.dcim.change_platform %}
@@ -166,7 +166,7 @@ UTILIZATION_GRAPH = """
"""
VIRTUALCHASSIS_ACTIONS = """
<a href="{% url 'dcim:virtualchassis_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Changelog">
<a href="{% url 'dcim:virtualchassis_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Change log">
<i class="fa fa-history"></i>
</a>
{% if perms.dcim.change_virtualchassis %}
@@ -795,11 +795,12 @@ class InterfaceTable(BaseTable):
class InterfaceDetailTable(DeviceComponentDetailTable):
parent = tables.LinkColumn(order_by=('device', 'virtual_machine'))
name = tables.LinkColumn()
enabled = BooleanColumn()
class Meta(InterfaceTable.Meta):
order_by = ('parent', 'name')
fields = ('pk', 'parent', 'name', 'type', 'description', 'cable')
sequence = ('pk', 'parent', 'name', 'type', 'description', 'cable')
fields = ('pk', 'parent', 'name', 'enabled', 'type', 'description', 'cable')
sequence = ('pk', 'parent', 'name', 'enabled', 'type', 'description', 'cable')
class FrontPortTable(BaseTable):

View File

@@ -596,6 +596,28 @@ class RackTest(APITestCase):
self.assertEqual(response.data['count'], 42)
def test_get_elevation_rack_units(self):
url = '{}?q=3'.format(reverse('dcim-api:rack-elevation', kwargs={'pk': self.rack1.pk}))
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 13)
url = '{}?q=U3'.format(reverse('dcim-api:rack-elevation', kwargs={'pk': self.rack1.pk}))
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 11)
url = '{}?q=10'.format(reverse('dcim-api:rack-elevation', kwargs={'pk': self.rack1.pk}))
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 1)
url = '{}?q=U20'.format(reverse('dcim-api:rack-elevation', kwargs={'pk': self.rack1.pk}))
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 1)
def test_get_rack_elevation(self):
url = reverse('dcim-api:rack-elevation', kwargs={'pk': self.rack1.pk})
@@ -1448,13 +1470,13 @@ class InterfaceTemplateTest(APITestCase):
manufacturer=self.manufacturer, model='Test Device Type 1', slug='test-device-type-1'
)
self.interfacetemplate1 = InterfaceTemplate.objects.create(
device_type=self.devicetype, name='Test Interface Template 1'
device_type=self.devicetype, name='Test Interface Template 1', type='1000base-t'
)
self.interfacetemplate2 = InterfaceTemplate.objects.create(
device_type=self.devicetype, name='Test Interface Template 2'
device_type=self.devicetype, name='Test Interface Template 2', type='1000base-t'
)
self.interfacetemplate3 = InterfaceTemplate.objects.create(
device_type=self.devicetype, name='Test Interface Template 3'
device_type=self.devicetype, name='Test Interface Template 3', type='1000base-t'
)
def test_get_interfacetemplate(self):
@@ -1476,6 +1498,7 @@ class InterfaceTemplateTest(APITestCase):
data = {
'device_type': self.devicetype.pk,
'name': 'Test Interface Template 4',
'type': '1000base-t',
}
url = reverse('dcim-api:interfacetemplate-list')
@@ -1493,14 +1516,17 @@ class InterfaceTemplateTest(APITestCase):
{
'device_type': self.devicetype.pk,
'name': 'Test Interface Template 4',
'type': '1000base-t',
},
{
'device_type': self.devicetype.pk,
'name': 'Test Interface Template 5',
'type': '1000base-t',
},
{
'device_type': self.devicetype.pk,
'name': 'Test Interface Template 6',
'type': '1000base-t',
},
]
@@ -1518,6 +1544,7 @@ class InterfaceTemplateTest(APITestCase):
data = {
'device_type': self.devicetype.pk,
'name': 'Test Interface Template X',
'type': '1000base-x-gbic',
}
url = reverse('dcim-api:interfacetemplate-detail', kwargs={'pk': self.interfacetemplate1.pk})
@@ -2628,9 +2655,9 @@ class InterfaceTest(APITestCase):
self.device = Device.objects.create(
device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site
)
self.interface1 = Interface.objects.create(device=self.device, name='Test Interface 1')
self.interface2 = Interface.objects.create(device=self.device, name='Test Interface 2')
self.interface3 = Interface.objects.create(device=self.device, name='Test Interface 3')
self.interface1 = Interface.objects.create(device=self.device, name='Test Interface 1', type='1000base-t')
self.interface2 = Interface.objects.create(device=self.device, name='Test Interface 2', type='1000base-t')
self.interface3 = Interface.objects.create(device=self.device, name='Test Interface 3', type='1000base-t')
self.vlan1 = VLAN.objects.create(name="Test VLAN 1", vid=1)
self.vlan2 = VLAN.objects.create(name="Test VLAN 2", vid=2)
@@ -2691,6 +2718,7 @@ class InterfaceTest(APITestCase):
data = {
'device': self.device.pk,
'name': 'Test Interface 4',
'type': '1000base-t',
}
url = reverse('dcim-api:interface-list')
@@ -2707,6 +2735,7 @@ class InterfaceTest(APITestCase):
data = {
'device': self.device.pk,
'name': 'Test Interface 4',
'type': '1000base-t',
'mode': InterfaceModeChoices.MODE_TAGGED,
'untagged_vlan': self.vlan3.id,
'tagged_vlans': [self.vlan1.id, self.vlan2.id],
@@ -2728,14 +2757,17 @@ class InterfaceTest(APITestCase):
{
'device': self.device.pk,
'name': 'Test Interface 4',
'type': '1000base-t',
},
{
'device': self.device.pk,
'name': 'Test Interface 5',
'type': '1000base-t',
},
{
'device': self.device.pk,
'name': 'Test Interface 6',
'type': '1000base-t',
},
]
@@ -2754,6 +2786,7 @@ class InterfaceTest(APITestCase):
{
'device': self.device.pk,
'name': 'Test Interface 4',
'type': '1000base-t',
'mode': InterfaceModeChoices.MODE_TAGGED,
'untagged_vlan': self.vlan2.id,
'tagged_vlans': [self.vlan1.id],
@@ -2761,6 +2794,7 @@ class InterfaceTest(APITestCase):
{
'device': self.device.pk,
'name': 'Test Interface 5',
'type': '1000base-t',
'mode': InterfaceModeChoices.MODE_TAGGED,
'untagged_vlan': self.vlan2.id,
'tagged_vlans': [self.vlan1.id],
@@ -2768,6 +2802,7 @@ class InterfaceTest(APITestCase):
{
'device': self.device.pk,
'name': 'Test Interface 6',
'type': '1000base-t',
'mode': InterfaceModeChoices.MODE_TAGGED,
'untagged_vlan': self.vlan2.id,
'tagged_vlans': [self.vlan1.id],
@@ -2793,6 +2828,7 @@ class InterfaceTest(APITestCase):
data = {
'device': self.device.pk,
'name': 'Test Interface X',
'type': '1000base-x-gbic',
'lag': lag_interface.pk,
}

View File

@@ -11,7 +11,7 @@ from dcim.models import (
VirtualChassis,
)
from ipam.models import IPAddress
from tenancy.models import Tenant
from tenancy.models import Tenant, TenantGroup
from virtualization.models import Cluster, ClusterType
@@ -76,10 +76,24 @@ class SiteTestCase(TestCase):
for region in regions:
region.save()
tenant_groups = (
TenantGroup(name='Tenant group 1', slug='tenant-group-1'),
TenantGroup(name='Tenant group 2', slug='tenant-group-2'),
TenantGroup(name='Tenant group 3', slug='tenant-group-3'),
)
TenantGroup.objects.bulk_create(tenant_groups)
tenants = (
Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]),
Tenant(name='Tenant 2', slug='tenant-2', group=tenant_groups[1]),
Tenant(name='Tenant 3', slug='tenant-3', group=tenant_groups[2]),
)
Tenant.objects.bulk_create(tenants)
sites = (
Site(name='Site 1', slug='site-1', region=regions[0], status=SiteStatusChoices.STATUS_ACTIVE, facility='Facility 1', asn=65001, latitude=10, longitude=10, contact_name='Contact 1', contact_phone='123-555-0001', contact_email='contact1@example.com'),
Site(name='Site 2', slug='site-2', region=regions[1], status=SiteStatusChoices.STATUS_PLANNED, facility='Facility 2', asn=65002, latitude=20, longitude=20, contact_name='Contact 2', contact_phone='123-555-0002', contact_email='contact2@example.com'),
Site(name='Site 3', slug='site-3', region=regions[2], status=SiteStatusChoices.STATUS_RETIRED, facility='Facility 3', asn=65003, latitude=30, longitude=30, contact_name='Contact 3', contact_phone='123-555-0003', contact_email='contact3@example.com'),
Site(name='Site 1', slug='site-1', region=regions[0], tenant=tenants[0], status=SiteStatusChoices.STATUS_ACTIVE, facility='Facility 1', asn=65001, latitude=10, longitude=10, contact_name='Contact 1', contact_phone='123-555-0001', contact_email='contact1@example.com'),
Site(name='Site 2', slug='site-2', region=regions[1], tenant=tenants[1], status=SiteStatusChoices.STATUS_PLANNED, facility='Facility 2', asn=65002, latitude=20, longitude=20, contact_name='Contact 2', contact_phone='123-555-0002', contact_email='contact2@example.com'),
Site(name='Site 3', slug='site-3', region=regions[2], tenant=tenants[2], status=SiteStatusChoices.STATUS_RETIRED, facility='Facility 3', asn=65003, latitude=30, longitude=30, contact_name='Contact 3', contact_phone='123-555-0003', contact_email='contact3@example.com'),
)
Site.objects.bulk_create(sites)
@@ -140,6 +154,20 @@ class SiteTestCase(TestCase):
params = {'region': [regions[0].slug, regions[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_tenant(self):
tenants = Tenant.objects.all()[:2]
params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'tenant': [tenants[0].slug, tenants[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_tenant_group(self):
tenant_groups = TenantGroup.objects.all()[:2]
params = {'tenant_group_id': [tenant_groups[0].pk, tenant_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class RackGroupTestCase(TestCase):
queryset = RackGroup.objects.all()
@@ -266,10 +294,24 @@ class RackTestCase(TestCase):
)
RackRole.objects.bulk_create(rack_roles)
tenant_groups = (
TenantGroup(name='Tenant group 1', slug='tenant-group-1'),
TenantGroup(name='Tenant group 2', slug='tenant-group-2'),
TenantGroup(name='Tenant group 3', slug='tenant-group-3'),
)
TenantGroup.objects.bulk_create(tenant_groups)
tenants = (
Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]),
Tenant(name='Tenant 2', slug='tenant-2', group=tenant_groups[1]),
Tenant(name='Tenant 3', slug='tenant-3', group=tenant_groups[2]),
)
Tenant.objects.bulk_create(tenants)
racks = (
Rack(name='Rack 1', facility_id='rack-1', site=sites[0], group=rack_groups[0], status=RackStatusChoices.STATUS_ACTIVE, role=rack_roles[0], serial='ABC', asset_tag='1001', type=RackTypeChoices.TYPE_2POST, width=RackWidthChoices.WIDTH_19IN, u_height=42, desc_units=False, outer_width=100, outer_depth=100, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER),
Rack(name='Rack 2', facility_id='rack-2', site=sites[1], group=rack_groups[1], status=RackStatusChoices.STATUS_PLANNED, role=rack_roles[1], serial='DEF', asset_tag='1002', type=RackTypeChoices.TYPE_4POST, width=RackWidthChoices.WIDTH_19IN, u_height=43, desc_units=False, outer_width=200, outer_depth=200, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER),
Rack(name='Rack 3', facility_id='rack-3', site=sites[2], group=rack_groups[2], status=RackStatusChoices.STATUS_RESERVED, role=rack_roles[2], serial='GHI', asset_tag='1003', type=RackTypeChoices.TYPE_CABINET, width=RackWidthChoices.WIDTH_23IN, u_height=44, desc_units=True, outer_width=300, outer_depth=300, outer_unit=RackDimensionUnitChoices.UNIT_INCH),
Rack(name='Rack 1', facility_id='rack-1', site=sites[0], group=rack_groups[0], tenant=tenants[0], status=RackStatusChoices.STATUS_ACTIVE, role=rack_roles[0], serial='ABC', asset_tag='1001', type=RackTypeChoices.TYPE_2POST, width=RackWidthChoices.WIDTH_19IN, u_height=42, desc_units=False, outer_width=100, outer_depth=100, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER),
Rack(name='Rack 2', facility_id='rack-2', site=sites[1], group=rack_groups[1], tenant=tenants[1], status=RackStatusChoices.STATUS_PLANNED, role=rack_roles[1], serial='DEF', asset_tag='1002', type=RackTypeChoices.TYPE_4POST, width=RackWidthChoices.WIDTH_19IN, u_height=43, desc_units=False, outer_width=200, outer_depth=200, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER),
Rack(name='Rack 3', facility_id='rack-3', site=sites[2], group=rack_groups[2], tenant=tenants[2], status=RackStatusChoices.STATUS_RESERVED, role=rack_roles[2], serial='GHI', asset_tag='1003', type=RackTypeChoices.TYPE_CABINET, width=RackWidthChoices.WIDTH_23IN, u_height=44, desc_units=True, outer_width=300, outer_depth=300, outer_unit=RackDimensionUnitChoices.UNIT_INCH),
)
Rack.objects.bulk_create(racks)
@@ -366,6 +408,20 @@ class RackTestCase(TestCase):
params = {'serial': 'abc'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_tenant(self):
tenants = Tenant.objects.all()[:2]
params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'tenant': [tenants[0].slug, tenants[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_tenant_group(self):
tenant_groups = TenantGroup.objects.all()[:2]
params = {'tenant_group_id': [tenant_groups[0].pk, tenant_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class RackReservationTestCase(TestCase):
queryset = RackReservation.objects.all()
@@ -402,10 +458,24 @@ class RackReservationTestCase(TestCase):
)
User.objects.bulk_create(users)
tenant_groups = (
TenantGroup(name='Tenant group 1', slug='tenant-group-1'),
TenantGroup(name='Tenant group 2', slug='tenant-group-2'),
TenantGroup(name='Tenant group 3', slug='tenant-group-3'),
)
TenantGroup.objects.bulk_create(tenant_groups)
tenants = (
Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]),
Tenant(name='Tenant 2', slug='tenant-2', group=tenant_groups[1]),
Tenant(name='Tenant 3', slug='tenant-3', group=tenant_groups[2]),
)
Tenant.objects.bulk_create(tenants)
reservations = (
RackReservation(rack=racks[0], units=[1, 2, 3], user=users[0]),
RackReservation(rack=racks[1], units=[4, 5, 6], user=users[1]),
RackReservation(rack=racks[2], units=[7, 8, 9], user=users[2]),
RackReservation(rack=racks[0], units=[1, 2, 3], user=users[0], tenant=tenants[0]),
RackReservation(rack=racks[1], units=[4, 5, 6], user=users[1], tenant=tenants[1]),
RackReservation(rack=racks[2], units=[7, 8, 9], user=users[2], tenant=tenants[2]),
)
RackReservation.objects.bulk_create(reservations)
@@ -436,6 +506,20 @@ class RackReservationTestCase(TestCase):
# params = {'user': [users[0].username, users[1].username]}
# self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_tenant(self):
tenants = Tenant.objects.all()[:2]
params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'tenant': [tenants[0].slug, tenants[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_tenant_group(self):
tenant_groups = TenantGroup.objects.all()[:2]
params = {'tenant_group_id': [tenant_groups[0].pk, tenant_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class ManufacturerTestCase(TestCase):
queryset = Manufacturer.objects.all()
@@ -1099,10 +1183,24 @@ class DeviceTestCase(TestCase):
)
Cluster.objects.bulk_create(clusters)
tenant_groups = (
TenantGroup(name='Tenant group 1', slug='tenant-group-1'),
TenantGroup(name='Tenant group 2', slug='tenant-group-2'),
TenantGroup(name='Tenant group 3', slug='tenant-group-3'),
)
TenantGroup.objects.bulk_create(tenant_groups)
tenants = (
Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]),
Tenant(name='Tenant 2', slug='tenant-2', group=tenant_groups[1]),
Tenant(name='Tenant 3', slug='tenant-3', group=tenant_groups[2]),
)
Tenant.objects.bulk_create(tenants)
devices = (
Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], platform=platforms[0], serial='ABC', asset_tag='1001', site=sites[0], rack=racks[0], position=1, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_ACTIVE, cluster=clusters[0], local_context_data={"foo": 123}),
Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], platform=platforms[1], serial='DEF', asset_tag='1002', site=sites[1], rack=racks[1], position=2, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_STAGED, cluster=clusters[1]),
Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], platform=platforms[2], serial='GHI', asset_tag='1003', site=sites[2], rack=racks[2], position=3, face=DeviceFaceChoices.FACE_REAR, status=DeviceStatusChoices.STATUS_FAILED, cluster=clusters[2]),
Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], platform=platforms[0], tenant=tenants[0], serial='ABC', asset_tag='1001', site=sites[0], rack=racks[0], position=1, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_ACTIVE, cluster=clusters[0], local_context_data={"foo": 123}),
Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], platform=platforms[1], tenant=tenants[1], serial='DEF', asset_tag='1002', site=sites[1], rack=racks[1], position=2, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_STAGED, cluster=clusters[1]),
Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], platform=platforms[2], tenant=tenants[2], serial='GHI', asset_tag='1003', site=sites[2], rack=racks[2], position=3, face=DeviceFaceChoices.FACE_REAR, status=DeviceStatusChoices.STATUS_FAILED, cluster=clusters[2]),
)
Device.objects.bulk_create(devices)
@@ -1333,6 +1431,20 @@ class DeviceTestCase(TestCase):
params = {'local_context_data': 'false'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_tenant(self):
tenants = Tenant.objects.all()[:2]
params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'tenant': [tenants[0].slug, tenants[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_tenant_group(self):
tenant_groups = TenantGroup.objects.all()[:2]
params = {'tenant_group_id': [tenant_groups[0].pk, tenant_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class ConsolePortTestCase(TestCase):
queryset = ConsolePort.objects.all()

View File

@@ -2,7 +2,6 @@ from django.core.exceptions import ValidationError
from django.test import TestCase
from dcim.choices import *
from dcim.constants import CONNECTION_STATUS_CONNECTED, CONNECTION_STATUS_PLANNED
from dcim.models import *
from tenancy.models import Tenant
@@ -522,14 +521,14 @@ class CablePathTestCase(TestCase):
cable3.save()
interface1 = Interface.objects.get(pk=self.interface1.pk)
self.assertEqual(interface1.connected_endpoint, self.interface2)
self.assertEqual(interface1.connection_status, CONNECTION_STATUS_PLANNED)
self.assertFalse(interface1.connection_status)
# Switch third segment from planned to connected
cable3.status = CableStatusChoices.STATUS_CONNECTED
cable3.save()
interface1 = Interface.objects.get(pk=self.interface1.pk)
self.assertEqual(interface1.connected_endpoint, self.interface2)
self.assertEqual(interface1.connection_status, CONNECTION_STATUS_CONNECTED)
self.assertTrue(interface1.connection_status)
def test_path_teardown(self):
@@ -542,7 +541,7 @@ class CablePathTestCase(TestCase):
cable3.save()
interface1 = Interface.objects.get(pk=self.interface1.pk)
self.assertEqual(interface1.connected_endpoint, self.interface2)
self.assertEqual(interface1.connection_status, CONNECTION_STATUS_CONNECTED)
self.assertTrue(interface1.connection_status)
# Remove a cable
cable2.delete()

View File

@@ -31,6 +31,7 @@ from utilities.views import (
from virtualization.models import VirtualMachine
from . import filters, forms, tables
from .choices import DeviceFaceChoices
from .constants import NONCONNECTABLE_IFACE_TYPES
from .models import (
Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
@@ -152,7 +153,6 @@ class RegionListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.RegionFilterSet
filterset_form = forms.RegionFilterForm
table = tables.RegionTable
template_name = 'dcim/region_list.html'
class RegionCreateView(PermissionRequiredMixin, ObjectEditView):
@@ -191,7 +191,6 @@ class SiteListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.SiteFilterSet
filterset_form = forms.SiteFilterForm
table = tables.SiteTable
template_name = 'dcim/site_list.html'
class SiteView(PermissionRequiredMixin, View):
@@ -271,7 +270,6 @@ class RackGroupListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.RackGroupFilterSet
filterset_form = forms.RackGroupFilterForm
table = tables.RackGroupTable
template_name = 'dcim/rackgroup_list.html'
class RackGroupCreateView(PermissionRequiredMixin, ObjectEditView):
@@ -308,7 +306,6 @@ class RackRoleListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'dcim.view_rackrole'
queryset = RackRole.objects.annotate(rack_count=Count('racks'))
table = tables.RackRoleTable
template_name = 'dcim/rackrole_list.html'
class RackRoleCreateView(PermissionRequiredMixin, ObjectEditView):
@@ -350,7 +347,6 @@ class RackListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.RackFilterSet
filterset_form = forms.RackFilterForm
table = tables.RackDetailTable
template_name = 'dcim/rack_list.html'
class RackElevationListView(PermissionRequiredMixin, View):
@@ -361,7 +357,7 @@ class RackElevationListView(PermissionRequiredMixin, View):
def get(self, request):
racks = Rack.objects.prefetch_related('site', 'group', 'tenant', 'role', 'devices__device_type')
racks = Rack.objects.prefetch_related('role')
racks = filters.RackFilterSet(request.GET, racks).qs
total_count = racks.count()
@@ -474,7 +470,7 @@ class RackReservationListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.RackReservationFilterSet
filterset_form = forms.RackReservationFilterForm
table = tables.RackReservationTable
template_name = 'dcim/rackreservation_list.html'
action_buttons = ()
class RackReservationCreateView(PermissionRequiredMixin, ObjectEditView):
@@ -533,7 +529,6 @@ class ManufacturerListView(PermissionRequiredMixin, ObjectListView):
platform_count=Count('platforms', distinct=True),
)
table = tables.ManufacturerTable
template_name = 'dcim/manufacturer_list.html'
class ManufacturerCreateView(PermissionRequiredMixin, ObjectEditView):
@@ -571,7 +566,6 @@ class DeviceTypeListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.DeviceTypeFilterSet
filterset_form = forms.DeviceTypeFilterForm
table = tables.DeviceTypeTable
template_name = 'dcim/devicetype_list.html'
class DeviceTypeView(PermissionRequiredMixin, View):
@@ -995,7 +989,6 @@ class DeviceRoleListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'dcim.view_devicerole'
queryset = DeviceRole.objects.all()
table = tables.DeviceRoleTable
template_name = 'dcim/devicerole_list.html'
class DeviceRoleCreateView(PermissionRequiredMixin, ObjectEditView):
@@ -1031,7 +1024,6 @@ class PlatformListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'dcim.view_platform'
queryset = Platform.objects.all()
table = tables.PlatformTable
template_name = 'dcim/platform_list.html'
class PlatformCreateView(PermissionRequiredMixin, ObjectEditView):
@@ -1190,7 +1182,7 @@ class DeviceLLDPNeighborsView(PermissionRequiredMixin, View):
def get(self, request, pk):
device = get_object_or_404(Device, pk=pk)
interfaces = device.vc_interfaces.connectable().prefetch_related(
interfaces = device.vc_interfaces.exclude(type__in=NONCONNECTABLE_IFACE_TYPES).prefetch_related(
'_connected_interface__device'
)
@@ -1292,7 +1284,7 @@ class ConsolePortListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.ConsolePortFilterSet
filterset_form = forms.ConsolePortFilterForm
table = tables.ConsolePortDetailTable
template_name = 'dcim/consoleport_list.html'
action_buttons = ('import', 'export')
class ConsolePortCreateView(PermissionRequiredMixin, ComponentCreateView):
@@ -1345,7 +1337,7 @@ class ConsoleServerPortListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.ConsoleServerPortFilterSet
filterset_form = forms.ConsoleServerPortFilterForm
table = tables.ConsoleServerPortDetailTable
template_name = 'dcim/consoleserverport_list.html'
action_buttons = ('import', 'export')
class ConsoleServerPortCreateView(PermissionRequiredMixin, ComponentCreateView):
@@ -1410,7 +1402,7 @@ class PowerPortListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.PowerPortFilterSet
filterset_form = forms.PowerPortFilterForm
table = tables.PowerPortDetailTable
template_name = 'dcim/powerport_list.html'
action_buttons = ('import', 'export')
class PowerPortCreateView(PermissionRequiredMixin, ComponentCreateView):
@@ -1463,7 +1455,7 @@ class PowerOutletListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.PowerOutletFilterSet
filterset_form = forms.PowerOutletFilterForm
table = tables.PowerOutletDetailTable
template_name = 'dcim/poweroutlet_list.html'
action_buttons = ('import', 'export')
class PowerOutletCreateView(PermissionRequiredMixin, ComponentCreateView):
@@ -1528,7 +1520,7 @@ class InterfaceListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.InterfaceFilterSet
filterset_form = forms.InterfaceFilterForm
table = tables.InterfaceDetailTable
template_name = 'dcim/interface_list.html'
action_buttons = ('import', 'export')
class InterfaceView(PermissionRequiredMixin, View):
@@ -1630,7 +1622,7 @@ class FrontPortListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.FrontPortFilterSet
filterset_form = forms.FrontPortFilterForm
table = tables.FrontPortDetailTable
template_name = 'dcim/frontport_list.html'
action_buttons = ('import', 'export')
class FrontPortCreateView(PermissionRequiredMixin, ComponentCreateView):
@@ -1695,7 +1687,7 @@ class RearPortListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.RearPortFilterSet
filterset_form = forms.RearPortFilterForm
table = tables.RearPortDetailTable
template_name = 'dcim/rearport_list.html'
action_buttons = ('import', 'export')
class RearPortCreateView(PermissionRequiredMixin, ComponentCreateView):
@@ -1762,7 +1754,7 @@ class DeviceBayListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.DeviceBayFilterSet
filterset_form = forms.DeviceBayFilterForm
table = tables.DeviceBayDetailTable
template_name = 'dcim/devicebay_list.html'
action_buttons = ('import', 'export')
class DeviceBayCreateView(PermissionRequiredMixin, ComponentCreateView):
@@ -1961,7 +1953,7 @@ class CableListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.CableFilterSet
filterset_form = forms.CableFilterForm
table = tables.CableTable
template_name = 'dcim/cable_list.html'
action_buttons = ('import', 'export')
class CableView(PermissionRequiredMixin, View):
@@ -2233,7 +2225,7 @@ class InventoryItemListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.InventoryItemFilterSet
filterset_form = forms.InventoryItemFilterForm
table = tables.InventoryItemTable
template_name = 'dcim/inventoryitem_list.html'
action_buttons = ('import', 'export')
class InventoryItemEditView(PermissionRequiredMixin, ObjectEditView):
@@ -2289,7 +2281,7 @@ class VirtualChassisListView(PermissionRequiredMixin, ObjectListView):
table = tables.VirtualChassisTable
filterset = filters.VirtualChassisFilterSet
filterset_form = forms.VirtualChassisFilterForm
template_name = 'dcim/virtualchassis_list.html'
action_buttons = ('export',)
class VirtualChassisCreateView(PermissionRequiredMixin, View):
@@ -2533,7 +2525,6 @@ class PowerPanelListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.PowerPanelFilterSet
filterset_form = forms.PowerPanelFilterForm
table = tables.PowerPanelTable
template_name = 'dcim/powerpanel_list.html'
class PowerPanelView(PermissionRequiredMixin, View):
@@ -2602,7 +2593,6 @@ class PowerFeedListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.PowerFeedFilterSet
filterset_form = forms.PowerFeedFilterForm
table = tables.PowerFeedTable
template_name = 'dcim/powerfeed_list.html'
class PowerFeedView(PermissionRequiredMixin, View):

View File

@@ -26,7 +26,7 @@ class WebhookForm(forms.ModelForm):
class Meta:
model = Webhook
exclude = []
exclude = ()
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -38,13 +38,35 @@ class WebhookForm(forms.ModelForm):
@admin.register(Webhook, site=admin_site)
class WebhookAdmin(admin.ModelAdmin):
list_display = [
'name', 'models', 'payload_url', 'http_content_type', 'enabled', 'type_create', 'type_update',
'type_delete', 'ssl_verification',
'name', 'models', 'payload_url', 'http_content_type', 'enabled', 'type_create', 'type_update', 'type_delete',
'ssl_verification',
]
list_filter = [
'enabled', 'type_create', 'type_update', 'type_delete', 'obj_type',
]
form = WebhookForm
fieldsets = (
(None, {
'fields': (
'name', 'obj_type', 'enabled',
)
}),
('Events', {
'fields': (
'type_create', 'type_update', 'type_delete',
)
}),
('HTTP Request', {
'fields': (
'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret',
)
}),
('SSL', {
'fields': (
'ssl_verification', 'ca_file_path',
)
})
)
def models(self, obj):
return ', '.join([ct.name for ct in obj.obj_type.all()])

View File

@@ -1,9 +1,11 @@
from datetime import datetime
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from django.db import transaction
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from rest_framework.fields import CreateOnlyDefault
from extras.choices import *
from extras.models import CustomField, CustomFieldChoice, CustomFieldValue
@@ -14,6 +16,43 @@ from utilities.api import ValidatedModelSerializer
# Custom fields
#
class CustomFieldDefaultValues:
"""
Return a dictionary of all CustomFields assigned to the parent model and their default values.
"""
def __call__(self):
# Retrieve the CustomFields for the parent model
content_type = ContentType.objects.get_for_model(self.model)
fields = CustomField.objects.filter(obj_type=content_type)
# Populate the default value for each CustomField
value = {}
for field in fields:
if field.default:
if field.type == CustomFieldTypeChoices.TYPE_INTEGER:
field_value = int(field.default)
elif field.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
# TODO: Fix default value assignment for boolean custom fields
field_value = False if field.default.lower() == 'false' else bool(field.default)
elif field.type == CustomFieldTypeChoices.TYPE_SELECT:
try:
field_value = field.choices.get(value=field.default).pk
except ObjectDoesNotExist:
# Invalid default value
field_value = None
else:
field_value = field.default
value[field.name] = field_value
else:
value[field.name] = None
return value
def set_context(self, serializer_field):
self.model = serializer_field.parent.Meta.model
class CustomFieldsSerializer(serializers.BaseSerializer):
def to_representation(self, obj):
@@ -94,53 +133,35 @@ class CustomFieldModelSerializer(ValidatedModelSerializer):
"""
Extends ModelSerializer to render any CustomFields and their values associated with an object.
"""
custom_fields = CustomFieldsSerializer(required=False)
custom_fields = CustomFieldsSerializer(
required=False,
default=CreateOnlyDefault(CustomFieldDefaultValues())
)
def __init__(self, *args, **kwargs):
def _populate_custom_fields(instance, fields):
instance.custom_fields = {}
for field in fields:
value = instance.cf.get(field.name)
if field.type == CustomFieldTypeChoices.TYPE_SELECT and value is not None:
instance.custom_fields[field.name] = CustomFieldChoiceSerializer(value).data
else:
instance.custom_fields[field.name] = value
super().__init__(*args, **kwargs)
# Retrieve the set of CustomFields which apply to this type of object
content_type = ContentType.objects.get_for_model(self.Meta.model)
fields = CustomField.objects.filter(obj_type=content_type)
if self.instance is not None:
# Retrieve the set of CustomFields which apply to this type of object
content_type = ContentType.objects.get_for_model(self.Meta.model)
fields = CustomField.objects.filter(obj_type=content_type)
# Populate CustomFieldValues for each instance from database
try:
for obj in self.instance:
_populate_custom_fields(obj, fields)
self._populate_custom_fields(obj, fields)
except TypeError:
_populate_custom_fields(self.instance, fields)
self._populate_custom_fields(self.instance, fields)
else:
if not hasattr(self, 'initial_data'):
self.initial_data = {}
# Populate default values
if fields and 'custom_fields' not in self.initial_data:
self.initial_data['custom_fields'] = {}
# Populate initial data using custom field default values
for field in fields:
if field.name not in self.initial_data['custom_fields'] and field.default:
if field.type == CustomFieldTypeChoices.TYPE_SELECT:
field_value = field.choices.get(value=field.default).pk
elif field.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
field_value = bool(field.default)
else:
field_value = field.default
self.initial_data['custom_fields'][field.name] = field_value
def _populate_custom_fields(self, instance, custom_fields):
instance.custom_fields = {}
for field in custom_fields:
value = instance.cf.get(field.name)
if field.type == CustomFieldTypeChoices.TYPE_SELECT and value is not None:
instance.custom_fields[field.name] = CustomFieldChoiceSerializer(value).data
else:
instance.custom_fields[field.name] = value
def _save_custom_fields(self, instance, custom_fields):
content_type = ContentType.objects.get_for_model(self.Meta.model)

View File

@@ -40,10 +40,14 @@ class GraphSerializer(ValidatedModelSerializer):
class RenderedGraphSerializer(serializers.ModelSerializer):
embed_url = serializers.SerializerMethodField()
embed_link = serializers.SerializerMethodField()
embed_url = serializers.SerializerMethodField(
read_only=True
)
embed_link = serializers.SerializerMethodField(
read_only=True
)
type = ContentTypeField(
queryset=ContentType.objects.all()
read_only=True
)
class Meta:
@@ -62,6 +66,9 @@ class RenderedGraphSerializer(serializers.ModelSerializer):
#
class ExportTemplateSerializer(ValidatedModelSerializer):
content_type = ContentTypeField(
queryset=ContentType.objects.filter(EXPORTTEMPLATE_MODELS),
)
template_language = ChoiceField(
choices=TemplateLanguageChoices,
default=TemplateLanguageChoices.LANGUAGE_JINJA2

View File

@@ -14,7 +14,7 @@ from extras.models import (
ConfigContext, CustomFieldChoice, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, Tag,
)
from extras.reports import get_report, get_reports
from extras.scripts import get_script, get_scripts
from extras.scripts import get_script, get_scripts, run_script
from utilities.api import FieldChoicesViewSet, IsAuthenticatedOrLoginNotRequired, ModelViewSet
from . import serializers
@@ -265,8 +265,9 @@ class ScriptViewSet(ViewSet):
input_serializer = serializers.ScriptInputSerializer(data=request.data)
if input_serializer.is_valid():
output = script.run(input_serializer.data['data'])
script.output = output
data = input_serializer.data['data']
commit = input_serializer.data['commit']
script.output, execution_time = run_script(script, data, request, commit)
output_serializer = serializers.ScriptOutputSerializer(script)
return Response(output_serializer.data)

View File

@@ -1,28 +1,8 @@
from django.apps import AppConfig
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
import redis
class ExtrasConfig(AppConfig):
name = "extras"
def ready(self):
import extras.signals
# Check that we can connect to the configured Redis database.
try:
rs = redis.Redis(
host=settings.WEBHOOKS_REDIS_HOST,
port=settings.WEBHOOKS_REDIS_PORT,
db=settings.WEBHOOKS_REDIS_DATABASE,
password=settings.WEBHOOKS_REDIS_PASSWORD or None,
ssl=settings.WEBHOOKS_REDIS_SSL,
)
rs.ping()
except redis.exceptions.ConnectionError:
raise ImproperlyConfigured(
"Unable to connect to the Redis database. Check that the Redis configuration has been defined in "
"configuration.py."
)

View File

@@ -124,17 +124,18 @@ class TemplateLanguageChoices(ChoiceSet):
# Webhooks
#
class WebhookContentTypeChoices(ChoiceSet):
class WebhookHttpMethodChoices(ChoiceSet):
CONTENTTYPE_JSON = 'application/json'
CONTENTTYPE_FORMDATA = 'application/x-www-form-urlencoded'
METHOD_GET = 'GET'
METHOD_POST = 'POST'
METHOD_PUT = 'PUT'
METHOD_PATCH = 'PATCH'
METHOD_DELETE = 'DELETE'
CHOICES = (
(CONTENTTYPE_JSON, 'JSON'),
(CONTENTTYPE_FORMDATA, 'Form data'),
(METHOD_GET, 'GET'),
(METHOD_POST, 'POST'),
(METHOD_PUT, 'PUT'),
(METHOD_PATCH, 'PATCH'),
(METHOD_DELETE, 'DELETE'),
)
LEGACY_MAP = {
CONTENTTYPE_JSON: 1,
CONTENTTYPE_FORMDATA: 2,
}

View File

@@ -138,6 +138,8 @@ LOG_LEVEL_CODES = {
LOG_FAILURE: 'failure',
}
HTTP_CONTENT_TYPE_JSON = 'application/json'
# Models which support registered webhooks
WEBHOOK_MODELS = Q(
Q(app_label='circuits', model__in=[

View File

@@ -4,6 +4,7 @@ from django.db.models import Q
from dcim.models import DeviceRole, Platform, Region, Site
from tenancy.models import Tenant, TenantGroup
from utilities.filters import BaseFilterSet
from virtualization.models import Cluster, ClusterGroup
from .choices import *
from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, Tag
@@ -89,21 +90,21 @@ class CustomFieldFilterSet(django_filters.FilterSet):
self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(field_name=cf.name, custom_field=cf)
class GraphFilterSet(django_filters.FilterSet):
class GraphFilterSet(BaseFilterSet):
class Meta:
model = Graph
fields = ['type', 'name', 'template_language']
class ExportTemplateFilterSet(django_filters.FilterSet):
class ExportTemplateFilterSet(BaseFilterSet):
class Meta:
model = ExportTemplate
fields = ['content_type', 'name', 'template_language']
class TagFilterSet(django_filters.FilterSet):
class TagFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@@ -122,7 +123,7 @@ class TagFilterSet(django_filters.FilterSet):
)
class ConfigContextFilterSet(django_filters.FilterSet):
class ConfigContextFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@@ -244,7 +245,7 @@ class LocalConfigContextFilterSet(django_filters.FilterSet):
return queryset.exclude(local_context_data__isnull=value)
class ObjectChangeFilterSet(django_filters.FilterSet):
class ObjectChangeFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',

View File

@@ -5,11 +5,14 @@ from copy import deepcopy
from datetime import timedelta
from django.conf import settings
from django.contrib import messages
from django.db.models.signals import pre_delete, post_save
from django.utils import timezone
from django_prometheus.models import model_deletes, model_inserts, model_updates
from redis.exceptions import RedisError
from extras.utils import is_taggable
from utilities.api import is_api_request
from utilities.querysets import DummyQuerySet
from .choices import ObjectChangeActionChoices
from .models import ObjectChange
@@ -98,7 +101,12 @@ class ObjectChangeMiddleware(object):
if not _thread_locals.changed_objects:
return response
# Disconnect our receivers from the post_save and post_delete signals.
post_save.disconnect(handle_changed_object, dispatch_uid='handle_changed_object')
pre_delete.disconnect(handle_deleted_object, dispatch_uid='handle_deleted_object')
# Create records for any cached objects that were changed.
redis_failed = False
for instance, action in _thread_locals.changed_objects:
# Refresh cached custom field values
@@ -114,7 +122,16 @@ class ObjectChangeMiddleware(object):
objectchange.save()
# Enqueue webhooks
enqueue_webhooks(instance, request.user, request.id, action)
try:
enqueue_webhooks(instance, request.user, request.id, action)
except RedisError as e:
if not redis_failed and not is_api_request(request):
messages.error(
request,
"There was an error processing webhooks for this request. Check that the Redis service is "
"running and reachable. The full error details were: {}".format(e)
)
redis_failed = True
# Increment metric counters
if action == ObjectChangeActionChoices.ACTION_CREATE:

View File

@@ -0,0 +1,48 @@
import json
from django.db import migrations, models
def json_to_text(apps, schema_editor):
"""
Convert a JSON representation of HTTP headers to key-value pairs (one header per line)
"""
Webhook = apps.get_model('extras', 'Webhook')
for webhook in Webhook.objects.exclude(additional_headers=''):
data = json.loads(webhook.additional_headers)
headers = ['{}: {}'.format(k, v) for k, v in data.items()]
Webhook.objects.filter(pk=webhook.pk).update(additional_headers='\n'.join(headers))
class Migration(migrations.Migration):
dependencies = [
('extras', '0037_configcontexts_clusters'),
]
operations = [
migrations.AddField(
model_name='webhook',
name='http_method',
field=models.CharField(default='POST', max_length=30),
),
migrations.AddField(
model_name='webhook',
name='body_template',
field=models.TextField(blank=True),
),
migrations.AlterField(
model_name='webhook',
name='additional_headers',
field=models.TextField(blank=True, default=''),
preserve_default=False,
),
migrations.AlterField(
model_name='webhook',
name='http_content_type',
field=models.CharField(default='application/json', max_length=100),
),
migrations.RunPython(
code=json_to_text
),
]

View File

@@ -1,3 +1,4 @@
import json
from collections import OrderedDict
from datetime import date
@@ -12,6 +13,7 @@ from django.http import HttpResponse
from django.template import Template, Context
from django.urls import reverse
from django.utils.text import slugify
from rest_framework.utils.encoders import JSONEncoder
from taggit.models import TagBase, GenericTaggedItemBase
from utilities.fields import ColorField
@@ -52,7 +54,6 @@ class Webhook(models.Model):
delete in NetBox. The request will contain a representation of the object, which the remote application can act on.
Each Webhook can be limited to firing only on certain actions or certain object types.
"""
obj_type = models.ManyToManyField(
to=ContentType,
related_name='webhooks',
@@ -81,17 +82,33 @@ class Webhook(models.Model):
verbose_name='URL',
help_text="A POST will be sent to this URL when the webhook is called."
)
http_content_type = models.CharField(
max_length=50,
choices=WebhookContentTypeChoices,
default=WebhookContentTypeChoices.CONTENTTYPE_JSON,
verbose_name='HTTP content type'
enabled = models.BooleanField(
default=True
)
additional_headers = JSONField(
null=True,
http_method = models.CharField(
max_length=30,
choices=WebhookHttpMethodChoices,
default=WebhookHttpMethodChoices.METHOD_POST,
verbose_name='HTTP method'
)
http_content_type = models.CharField(
max_length=100,
default=HTTP_CONTENT_TYPE_JSON,
verbose_name='HTTP content type',
help_text='The complete list of official content types is available '
'<a href="https://www.iana.org/assignments/media-types/media-types.xhtml">here</a>.'
)
additional_headers = models.TextField(
blank=True,
help_text="User supplied headers which should be added to the request in addition to the HTTP content type. "
"Headers are supplied as key/value pairs in a JSON object."
help_text="User-supplied HTTP headers to be sent with the request in addition to the HTTP content type. "
"Headers should be defined in the format <code>Name: Value</code>. Jinja2 template processing is "
"support with the same context as the request body (below)."
)
body_template = models.TextField(
blank=True,
help_text='Jinja2 template for a custom request body. If blank, a JSON object representing the change will be '
'included. Available context data includes: <code>event</code>, <code>model</code>, '
'<code>timestamp</code>, <code>username</code>, <code>request_id</code>, and <code>data</code>.'
)
secret = models.CharField(
max_length=255,
@@ -101,9 +118,6 @@ class Webhook(models.Model):
"the secret as the key. The secret is not transmitted in "
"the request."
)
enabled = models.BooleanField(
default=True
)
ssl_verification = models.BooleanField(
default=True,
verbose_name='SSL verification',
@@ -126,9 +140,6 @@ class Webhook(models.Model):
return self.name
def clean(self):
"""
Validate model
"""
if not self.type_create and not self.type_delete and not self.type_update:
raise ValidationError(
"You must select at least one type: create, update, and/or delete."
@@ -136,14 +147,30 @@ class Webhook(models.Model):
if not self.ssl_verification and self.ca_file_path:
raise ValidationError({
'ca_file_path': 'Do not specify a CA certificate file if SSL verification is dissabled.'
'ca_file_path': 'Do not specify a CA certificate file if SSL verification is disabled.'
})
# Verify that JSON data is provided as an object
if self.additional_headers and type(self.additional_headers) is not dict:
raise ValidationError({
'additional_headers': 'Header JSON data must be in object form. Example: {"X-API-KEY": "abc123"}'
})
def render_headers(self, context):
"""
Render additional_headers and return a dict of Header: Value pairs.
"""
if not self.additional_headers:
return {}
ret = {}
data = render_jinja2(self.additional_headers, context)
for line in data.splitlines():
header, value = line.split(':')
ret[header.strip()] = value.strip()
return ret
def render_body(self, context):
"""
Render the body template, if defined. Otherwise, jump the context as a JSON object.
"""
if self.body_template:
return render_jinja2(self.body_template, context)
else:
return json.dumps(context, cls=JSONEncoder)
#

View File

@@ -63,10 +63,6 @@ class ScriptVariable:
self.field_attrs['widget'] = widget
self.field_attrs['required'] = required
# Initialize the list of optional validators if none have already been defined
if 'validators' not in self.field_attrs:
self.field_attrs['validators'] = []
def as_field(self):
"""
Render the variable as a Django form field.
@@ -227,14 +223,12 @@ class IPNetworkVar(ScriptVariable):
An IPv4 or IPv6 prefix.
"""
form_field = IPNetworkFormField
field_attrs = {
'validators': [prefix_validator]
}
def __init__(self, min_prefix_length=None, max_prefix_length=None, *args, **kwargs):
super().__init__(*args, **kwargs)
# Optional minimum/maximum prefix lengths
# Set prefix validator and optional minimum/maximum prefix lengths
self.field_attrs['validators'] = [prefix_validator]
if min_prefix_length is not None:
self.field_attrs['validators'].append(
MinPrefixLengthValidator(min_prefix_length)
@@ -292,7 +286,7 @@ class BaseScript:
return vars
def run(self, data):
def run(self, data, commit):
raise NotImplementedError("The script must define a run() method.")
def as_form(self, data=None, files=None, initial=None):
@@ -389,10 +383,17 @@ def run_script(script, data, request, commit=True):
# Add the current request as a property of the script
script.request = request
# Determine whether the script accepts a 'commit' argument (this was introduced in v2.7.8)
kwargs = {
'data': data
}
if 'commit' in inspect.signature(script.run).parameters:
kwargs['commit'] = commit
try:
with transaction.atomic():
start_time = time.time()
output = script.run(data)
output = script.run(**kwargs)
end_time = time.time()
if not commit:
raise AbortTransaction()

View File

@@ -5,7 +5,7 @@ from utilities.tables import BaseTable, BooleanColumn, ColorColumn, ToggleColumn
from .models import ConfigContext, ObjectChange, Tag, TaggedItem
TAG_ACTIONS = """
<a href="{% url 'extras:tag_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
<a href="{% url 'extras:tag_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
<i class="fa fa-history"></i>
</a>
{% if perms.taggit.change_tag %}

View File

@@ -163,17 +163,17 @@ class ExportTemplateTest(APITestCase):
super().setUp()
self.content_type = ContentType.objects.get_for_model(Device)
content_type = ContentType.objects.get_for_model(Device)
self.exporttemplate1 = ExportTemplate.objects.create(
content_type=self.content_type, name='Test Export Template 1',
content_type=content_type, name='Test Export Template 1',
template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
)
self.exporttemplate2 = ExportTemplate.objects.create(
content_type=self.content_type, name='Test Export Template 2',
content_type=content_type, name='Test Export Template 2',
template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
)
self.exporttemplate3 = ExportTemplate.objects.create(
content_type=self.content_type, name='Test Export Template 3',
content_type=content_type, name='Test Export Template 3',
template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
)
@@ -194,7 +194,7 @@ class ExportTemplateTest(APITestCase):
def test_create_exporttemplate(self):
data = {
'content_type': self.content_type.pk,
'content_type': 'dcim.device',
'name': 'Test Export Template 4',
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
}
@@ -205,7 +205,7 @@ class ExportTemplateTest(APITestCase):
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(ExportTemplate.objects.count(), 4)
exporttemplate4 = ExportTemplate.objects.get(pk=response.data['id'])
self.assertEqual(exporttemplate4.content_type_id, data['content_type'])
self.assertEqual(exporttemplate4.content_type, ContentType.objects.get_for_model(Device))
self.assertEqual(exporttemplate4.name, data['name'])
self.assertEqual(exporttemplate4.template_code, data['template_code'])
@@ -213,17 +213,17 @@ class ExportTemplateTest(APITestCase):
data = [
{
'content_type': self.content_type.pk,
'content_type': 'dcim.device',
'name': 'Test Export Template 4',
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
},
{
'content_type': self.content_type.pk,
'content_type': 'dcim.device',
'name': 'Test Export Template 5',
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
},
{
'content_type': self.content_type.pk,
'content_type': 'dcim.device',
'name': 'Test Export Template 6',
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
},
@@ -241,7 +241,7 @@ class ExportTemplateTest(APITestCase):
def test_update_exporttemplate(self):
data = {
'content_type': self.content_type.pk,
'content_type': 'dcim.device',
'name': 'Test Export Template X',
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
}
@@ -582,7 +582,7 @@ class ScriptTest(APITestCase):
var2 = IntegerVar()
var3 = BooleanVar()
def run(self, data):
def run(self, data, commit=True):
self.log_info(data['var1'])
self.log_success(data['var2'])

View File

@@ -101,240 +101,329 @@ class CustomFieldTest(TestCase):
class CustomFieldAPITest(APITestCase):
def setUp(self):
super().setUp()
@classmethod
def setUpTestData(cls):
content_type = ContentType.objects.get_for_model(Site)
# Text custom field
self.cf_text = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='magic_word')
self.cf_text.save()
self.cf_text.obj_type.set([content_type])
self.cf_text.save()
cls.cf_text = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo')
cls.cf_text.save()
cls.cf_text.obj_type.set([content_type])
# Integer custom field
self.cf_integer = CustomField(type=CustomFieldTypeChoices.TYPE_INTEGER, name='magic_number')
self.cf_integer.save()
self.cf_integer.obj_type.set([content_type])
self.cf_integer.save()
cls.cf_integer = CustomField(type=CustomFieldTypeChoices.TYPE_INTEGER, name='number_field', default=123)
cls.cf_integer.save()
cls.cf_integer.obj_type.set([content_type])
# Boolean custom field
self.cf_boolean = CustomField(type=CustomFieldTypeChoices.TYPE_BOOLEAN, name='is_magic')
self.cf_boolean.save()
self.cf_boolean.obj_type.set([content_type])
self.cf_boolean.save()
cls.cf_boolean = CustomField(type=CustomFieldTypeChoices.TYPE_BOOLEAN, name='boolean_field', default=False)
cls.cf_boolean.save()
cls.cf_boolean.obj_type.set([content_type])
# Date custom field
self.cf_date = CustomField(type=CustomFieldTypeChoices.TYPE_DATE, name='magic_date')
self.cf_date.save()
self.cf_date.obj_type.set([content_type])
self.cf_date.save()
cls.cf_date = CustomField(type=CustomFieldTypeChoices.TYPE_DATE, name='date_field', default='2020-01-01')
cls.cf_date.save()
cls.cf_date.obj_type.set([content_type])
# URL custom field
self.cf_url = CustomField(type=CustomFieldTypeChoices.TYPE_URL, name='magic_url')
self.cf_url.save()
self.cf_url.obj_type.set([content_type])
self.cf_url.save()
cls.cf_url = CustomField(type=CustomFieldTypeChoices.TYPE_URL, name='url_field', default='http://example.com/1')
cls.cf_url.save()
cls.cf_url.obj_type.set([content_type])
# Select custom field
self.cf_select = CustomField(type=CustomFieldTypeChoices.TYPE_SELECT, name='magic_choice')
self.cf_select.save()
self.cf_select.obj_type.set([content_type])
self.cf_select.save()
self.cf_select_choice1 = CustomFieldChoice(field=self.cf_select, value='Foo')
self.cf_select_choice1.save()
self.cf_select_choice2 = CustomFieldChoice(field=self.cf_select, value='Bar')
self.cf_select_choice2.save()
self.cf_select_choice3 = CustomFieldChoice(field=self.cf_select, value='Baz')
self.cf_select_choice3.save()
cls.cf_select = CustomField(type=CustomFieldTypeChoices.TYPE_SELECT, name='choice_field')
cls.cf_select.save()
cls.cf_select.obj_type.set([content_type])
cls.cf_select_choice1 = CustomFieldChoice(field=cls.cf_select, value='Foo')
cls.cf_select_choice1.save()
cls.cf_select_choice2 = CustomFieldChoice(field=cls.cf_select, value='Bar')
cls.cf_select_choice2.save()
cls.cf_select_choice3 = CustomFieldChoice(field=cls.cf_select, value='Baz')
cls.cf_select_choice3.save()
self.site = Site.objects.create(name='Test Site 1', slug='test-site-1')
cls.cf_select.default = cls.cf_select_choice1.value
cls.cf_select.save()
def test_get_obj_without_custom_fields(self):
# Create some sites
cls.sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
)
Site.objects.bulk_create(cls.sites)
url = reverse('dcim-api:site-detail', kwargs={'pk': self.site.pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.site.name)
self.assertEqual(response.data['custom_fields'], {
'magic_word': None,
'magic_number': None,
'is_magic': None,
'magic_date': None,
'magic_url': None,
'magic_choice': None,
})
def test_get_obj_with_custom_fields(self):
CUSTOM_FIELD_VALUES = [
(self.cf_text, 'Test string'),
(self.cf_integer, 1234),
(self.cf_boolean, True),
(self.cf_date, date(2016, 6, 23)),
(self.cf_url, 'http://example.com/'),
(self.cf_select, self.cf_select_choice1.pk),
]
for field, value in CUSTOM_FIELD_VALUES:
cfv = CustomFieldValue(field=field, obj=self.site)
# Assign custom field values for site 2
site2_cfvs = {
cls.cf_text: 'bar',
cls.cf_integer: 456,
cls.cf_boolean: True,
cls.cf_date: '2020-01-02',
cls.cf_url: 'http://example.com/2',
cls.cf_select: cls.cf_select_choice2.pk,
}
for field, value in site2_cfvs.items():
cfv = CustomFieldValue(field=field, obj=cls.sites[1])
cfv.value = value
cfv.save()
url = reverse('dcim-api:site-detail', kwargs={'pk': self.site.pk})
def test_get_single_object_without_custom_field_values(self):
"""
Validate that custom fields are present on an object even if it has no values defined.
"""
url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[0].pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.site.name)
self.assertEqual(response.data['custom_fields'].get('magic_word'), CUSTOM_FIELD_VALUES[0][1])
self.assertEqual(response.data['custom_fields'].get('magic_number'), CUSTOM_FIELD_VALUES[1][1])
self.assertEqual(response.data['custom_fields'].get('is_magic'), CUSTOM_FIELD_VALUES[2][1])
self.assertEqual(response.data['custom_fields'].get('magic_date'), CUSTOM_FIELD_VALUES[3][1])
self.assertEqual(response.data['custom_fields'].get('magic_url'), CUSTOM_FIELD_VALUES[4][1])
self.assertEqual(response.data['custom_fields'].get('magic_choice'), {
'value': self.cf_select_choice1.pk, 'label': 'Foo'
self.assertEqual(response.data['name'], self.sites[0].name)
self.assertEqual(response.data['custom_fields'], {
'text_field': None,
'number_field': None,
'boolean_field': None,
'date_field': None,
'url_field': None,
'choice_field': None,
})
def test_set_custom_field_text(self):
data = {
'name': 'Test Site 1',
'slug': 'test-site-1',
'custom_fields': {
'magic_word': 'Foo bar baz',
}
}
url = reverse('dcim-api:site-detail', kwargs={'pk': self.site.pk})
response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['custom_fields'].get('magic_word'), data['custom_fields']['magic_word'])
cfv = self.site.custom_field_values.get(field=self.cf_text)
self.assertEqual(cfv.value, data['custom_fields']['magic_word'])
def test_set_custom_field_integer(self):
data = {
'name': 'Test Site 1',
'slug': 'test-site-1',
'custom_fields': {
'magic_number': 42,
}
}
url = reverse('dcim-api:site-detail', kwargs={'pk': self.site.pk})
response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['custom_fields'].get('magic_number'), data['custom_fields']['magic_number'])
cfv = self.site.custom_field_values.get(field=self.cf_integer)
self.assertEqual(cfv.value, data['custom_fields']['magic_number'])
def test_set_custom_field_boolean(self):
data = {
'name': 'Test Site 1',
'slug': 'test-site-1',
'custom_fields': {
'is_magic': 0,
}
}
url = reverse('dcim-api:site-detail', kwargs={'pk': self.site.pk})
response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['custom_fields'].get('is_magic'), data['custom_fields']['is_magic'])
cfv = self.site.custom_field_values.get(field=self.cf_boolean)
self.assertEqual(cfv.value, data['custom_fields']['is_magic'])
def test_set_custom_field_date(self):
data = {
'name': 'Test Site 1',
'slug': 'test-site-1',
'custom_fields': {
'magic_date': '2017-04-25',
}
}
url = reverse('dcim-api:site-detail', kwargs={'pk': self.site.pk})
response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['custom_fields'].get('magic_date'), data['custom_fields']['magic_date'])
cfv = self.site.custom_field_values.get(field=self.cf_date)
self.assertEqual(cfv.value.isoformat(), data['custom_fields']['magic_date'])
def test_set_custom_field_url(self):
data = {
'name': 'Test Site 1',
'slug': 'test-site-1',
'custom_fields': {
'magic_url': 'http://example.com/2/',
}
}
url = reverse('dcim-api:site-detail', kwargs={'pk': self.site.pk})
response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['custom_fields'].get('magic_url'), data['custom_fields']['magic_url'])
cfv = self.site.custom_field_values.get(field=self.cf_url)
self.assertEqual(cfv.value, data['custom_fields']['magic_url'])
def test_set_custom_field_select(self):
data = {
'name': 'Test Site 1',
'slug': 'test-site-1',
'custom_fields': {
'magic_choice': self.cf_select_choice2.pk,
}
}
url = reverse('dcim-api:site-detail', kwargs={'pk': self.site.pk})
response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['custom_fields'].get('magic_choice'), data['custom_fields']['magic_choice'])
cfv = self.site.custom_field_values.get(field=self.cf_select)
self.assertEqual(cfv.value.pk, data['custom_fields']['magic_choice'])
def test_set_custom_field_defaults(self):
def test_get_single_object_with_custom_field_values(self):
"""
Create a new object with no custom field data. Custom field values should be created using the custom fields'
default values.
Validate that custom fields are present and correctly set for an object with values defined.
"""
CUSTOM_FIELD_DEFAULTS = {
'magic_word': 'foobar',
'magic_number': '123',
'is_magic': 'true',
'magic_date': '2019-12-13',
'magic_url': 'http://example.com/',
'magic_choice': self.cf_select_choice1.value,
site2_cfvs = {
cfv.field.name: cfv.value for cfv in self.sites[1].custom_field_values.all()
}
# Update CustomFields to set default values
for field_name, default_value in CUSTOM_FIELD_DEFAULTS.items():
CustomField.objects.filter(name=field_name).update(default=default_value)
url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.sites[1].name)
self.assertEqual(response.data['custom_fields']['text_field'], site2_cfvs['text_field'])
self.assertEqual(response.data['custom_fields']['number_field'], site2_cfvs['number_field'])
self.assertEqual(response.data['custom_fields']['boolean_field'], site2_cfvs['boolean_field'])
self.assertEqual(response.data['custom_fields']['date_field'], site2_cfvs['date_field'])
self.assertEqual(response.data['custom_fields']['url_field'], site2_cfvs['url_field'])
self.assertEqual(response.data['custom_fields']['choice_field']['label'], self.cf_select_choice2.value)
def test_create_single_object_with_defaults(self):
"""
Create a new site with no specified custom field values and check that it received the default values.
"""
data = {
'name': 'Test Site X',
'slug': 'test-site-x',
'name': 'Site 3',
'slug': 'site-3',
}
url = reverse('dcim-api:site-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(response.data['custom_fields']['magic_word'], CUSTOM_FIELD_DEFAULTS['magic_word'])
self.assertEqual(response.data['custom_fields']['magic_number'], str(CUSTOM_FIELD_DEFAULTS['magic_number']))
self.assertEqual(response.data['custom_fields']['is_magic'], bool(CUSTOM_FIELD_DEFAULTS['is_magic']))
self.assertEqual(response.data['custom_fields']['magic_date'], CUSTOM_FIELD_DEFAULTS['magic_date'])
self.assertEqual(response.data['custom_fields']['magic_url'], CUSTOM_FIELD_DEFAULTS['magic_url'])
self.assertEqual(response.data['custom_fields']['magic_choice'], self.cf_select_choice1.pk)
# Validate response data
response_cf = response.data['custom_fields']
self.assertEqual(response_cf['text_field'], self.cf_text.default)
self.assertEqual(response_cf['number_field'], self.cf_integer.default)
self.assertEqual(response_cf['boolean_field'], self.cf_boolean.default)
self.assertEqual(response_cf['date_field'], self.cf_date.default)
self.assertEqual(response_cf['url_field'], self.cf_url.default)
self.assertEqual(response_cf['choice_field'], self.cf_select_choice1.pk)
# Validate database data
site = Site.objects.get(pk=response.data['id'])
cfvs = {
cfv.field.name: cfv.value for cfv in site.custom_field_values.all()
}
self.assertEqual(cfvs['text_field'], self.cf_text.default)
self.assertEqual(cfvs['number_field'], self.cf_integer.default)
self.assertEqual(cfvs['boolean_field'], self.cf_boolean.default)
self.assertEqual(str(cfvs['date_field']), self.cf_date.default)
self.assertEqual(cfvs['url_field'], self.cf_url.default)
self.assertEqual(cfvs['choice_field'].pk, self.cf_select_choice1.pk)
def test_create_single_object_with_values(self):
"""
Create a single new site with a value for each type of custom field.
"""
data = {
'name': 'Site 3',
'slug': 'site-3',
'custom_fields': {
'text_field': 'bar',
'number_field': 456,
'boolean_field': True,
'date_field': '2020-01-02',
'url_field': 'http://example.com/2',
'choice_field': self.cf_select_choice2.pk,
},
}
url = reverse('dcim-api:site-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
# Validate response data
response_cf = response.data['custom_fields']
data_cf = data['custom_fields']
self.assertEqual(response_cf['text_field'], data_cf['text_field'])
self.assertEqual(response_cf['number_field'], data_cf['number_field'])
self.assertEqual(response_cf['boolean_field'], data_cf['boolean_field'])
self.assertEqual(response_cf['date_field'], data_cf['date_field'])
self.assertEqual(response_cf['url_field'], data_cf['url_field'])
self.assertEqual(response_cf['choice_field'], data_cf['choice_field'])
# Validate database data
site = Site.objects.get(pk=response.data['id'])
cfvs = {
cfv.field.name: cfv.value for cfv in site.custom_field_values.all()
}
self.assertEqual(cfvs['text_field'], data_cf['text_field'])
self.assertEqual(cfvs['number_field'], data_cf['number_field'])
self.assertEqual(cfvs['boolean_field'], data_cf['boolean_field'])
self.assertEqual(str(cfvs['date_field']), data_cf['date_field'])
self.assertEqual(cfvs['url_field'], data_cf['url_field'])
self.assertEqual(cfvs['choice_field'].pk, data_cf['choice_field'])
def test_create_multiple_objects_with_defaults(self):
"""
Create three news sites with no specified custom field values and check that each received
the default custom field values.
"""
data = (
{
'name': 'Site 3',
'slug': 'site-3',
},
{
'name': 'Site 4',
'slug': 'site-4',
},
{
'name': 'Site 5',
'slug': 'site-5',
},
)
url = reverse('dcim-api:site-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(len(response.data), len(data))
for i, obj in enumerate(data):
# Validate response data
response_cf = response.data[i]['custom_fields']
self.assertEqual(response_cf['text_field'], self.cf_text.default)
self.assertEqual(response_cf['number_field'], self.cf_integer.default)
self.assertEqual(response_cf['boolean_field'], self.cf_boolean.default)
self.assertEqual(response_cf['date_field'], self.cf_date.default)
self.assertEqual(response_cf['url_field'], self.cf_url.default)
self.assertEqual(response_cf['choice_field'], self.cf_select_choice1.pk)
# Validate database data
site = Site.objects.get(pk=response.data[i]['id'])
cfvs = {
cfv.field.name: cfv.value for cfv in site.custom_field_values.all()
}
self.assertEqual(cfvs['text_field'], self.cf_text.default)
self.assertEqual(cfvs['number_field'], self.cf_integer.default)
self.assertEqual(cfvs['boolean_field'], self.cf_boolean.default)
self.assertEqual(str(cfvs['date_field']), self.cf_date.default)
self.assertEqual(cfvs['url_field'], self.cf_url.default)
self.assertEqual(cfvs['choice_field'].pk, self.cf_select_choice1.pk)
def test_create_multiple_objects_with_values(self):
"""
Create a three new sites, each with custom fields defined.
"""
custom_field_data = {
'text_field': 'bar',
'number_field': 456,
'boolean_field': True,
'date_field': '2020-01-02',
'url_field': 'http://example.com/2',
'choice_field': self.cf_select_choice2.pk,
}
data = (
{
'name': 'Site 3',
'slug': 'site-3',
'custom_fields': custom_field_data,
},
{
'name': 'Site 4',
'slug': 'site-4',
'custom_fields': custom_field_data,
},
{
'name': 'Site 5',
'slug': 'site-5',
'custom_fields': custom_field_data,
},
)
url = reverse('dcim-api:site-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(len(response.data), len(data))
for i, obj in enumerate(data):
# Validate response data
response_cf = response.data[i]['custom_fields']
self.assertEqual(response_cf['text_field'], custom_field_data['text_field'])
self.assertEqual(response_cf['number_field'], custom_field_data['number_field'])
self.assertEqual(response_cf['boolean_field'], custom_field_data['boolean_field'])
self.assertEqual(response_cf['date_field'], custom_field_data['date_field'])
self.assertEqual(response_cf['url_field'], custom_field_data['url_field'])
self.assertEqual(response_cf['choice_field'], custom_field_data['choice_field'])
# Validate database data
site = Site.objects.get(pk=response.data[i]['id'])
cfvs = {
cfv.field.name: cfv.value for cfv in site.custom_field_values.all()
}
self.assertEqual(cfvs['text_field'], custom_field_data['text_field'])
self.assertEqual(cfvs['number_field'], custom_field_data['number_field'])
self.assertEqual(cfvs['boolean_field'], custom_field_data['boolean_field'])
self.assertEqual(str(cfvs['date_field']), custom_field_data['date_field'])
self.assertEqual(cfvs['url_field'], custom_field_data['url_field'])
self.assertEqual(cfvs['choice_field'].pk, custom_field_data['choice_field'])
def test_update_single_object_with_values(self):
"""
Update an object with existing custom field values. Ensure that only the updated custom field values are
modified.
"""
site2_original_cfvs = {
cfv.field.name: cfv.value for cfv in self.sites[1].custom_field_values.all()
}
data = {
'custom_fields': {
'text_field': 'ABCD',
'number_field': 1234,
},
}
url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk})
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
# Validate response data
response_cf = response.data['custom_fields']
data_cf = data['custom_fields']
self.assertEqual(response_cf['text_field'], data_cf['text_field'])
self.assertEqual(response_cf['number_field'], data_cf['number_field'])
# TODO: Non-updated fields are missing from the response data
# self.assertEqual(response_cf['boolean_field'], site2_original_cfvs['boolean_field'])
# self.assertEqual(response_cf['date_field'], site2_original_cfvs['date_field'])
# self.assertEqual(response_cf['url_field'], site2_original_cfvs['url_field'])
# self.assertEqual(response_cf['choice_field']['label'], site2_original_cfvs['choice_field'].value)
# Validate database data
site2_updated_cfvs = {
cfv.field.name: cfv.value for cfv in self.sites[1].custom_field_values.all()
}
self.assertEqual(site2_updated_cfvs['text_field'], data_cf['text_field'])
self.assertEqual(site2_updated_cfvs['number_field'], data_cf['number_field'])
self.assertEqual(site2_updated_cfvs['boolean_field'], site2_original_cfvs['boolean_field'])
self.assertEqual(site2_updated_cfvs['date_field'], site2_original_cfvs['date_field'])
self.assertEqual(site2_updated_cfvs['url_field'], site2_original_cfvs['url_field'])
self.assertEqual(site2_updated_cfvs['choice_field'], site2_original_cfvs['choice_field'])
class CustomFieldChoiceAPITest(APITestCase):

View File

@@ -28,8 +28,8 @@ class GraphTestCase(TestCase):
Graph.objects.bulk_create(graphs)
def test_name(self):
params = {'name': 'Graph 1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'name': ['Graph 1', 'Graph 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_type(self):
content_type = ContentType.objects.filter(GRAPH_MODELS).first()
@@ -59,8 +59,8 @@ class ExportTemplateTestCase(TestCase):
ExportTemplate.objects.bulk_create(export_templates)
def test_name(self):
params = {'name': 'Export Template 1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'name': ['Export Template 1', 'Export Template 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_content_type(self):
params = {'content_type': ContentType.objects.get(model='site').pk}
@@ -154,8 +154,8 @@ class ConfigContextTestCase(TestCase):
c.tenants.set([tenants[i]])
def test_name(self):
params = {'name': 'Config Context 1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'name': ['Config Context 1', 'Config Context 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_is_active(self):
params = {'is_active': True}

View File

@@ -34,7 +34,7 @@ class WebhookTest(APITestCase):
DUMMY_SECRET = "LOOKATMEIMASECRETSTRING"
webhooks = Webhook.objects.bulk_create((
Webhook(name='Site Create Webhook', type_create=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET, additional_headers={'X-Foo': 'Bar'}),
Webhook(name='Site Create Webhook', type_create=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET, additional_headers='X-Foo: Bar'),
Webhook(name='Site Update Webhook', type_update=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET),
Webhook(name='Site Delete Webhook', type_delete=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET),
))

View File

@@ -12,6 +12,7 @@ from django_tables2 import RequestConfig
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator
from utilities.utils import shallow_compare_dict
from utilities.views import BulkDeleteView, BulkEditView, ObjectDeleteView, ObjectEditView, ObjectListView
from . import filters, forms
from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult, Tag, TaggedItem
@@ -34,7 +35,7 @@ class TagListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.TagFilterSet
filterset_form = forms.TagFilterForm
table = TagTable
template_name = 'extras/tag_list.html'
action_buttons = ()
class TagView(PermissionRequiredMixin, View):
@@ -111,7 +112,7 @@ class ConfigContextListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.ConfigContextFilterSet
filterset_form = forms.ConfigContextFilterForm
table = ConfigContextTable
template_name = 'extras/configcontext_list.html'
action_buttons = ('add',)
class ConfigContextView(PermissionRequiredMixin, View):
@@ -191,6 +192,7 @@ class ObjectChangeListView(PermissionRequiredMixin, ObjectListView):
filterset_form = forms.ObjectChangeFilterForm
table = ObjectChangeTable
template_name = 'extras/objectchange_list.html'
action_buttons = ('export',)
class ObjectChangeView(PermissionRequiredMixin, View):
@@ -206,8 +208,31 @@ class ObjectChangeView(PermissionRequiredMixin, View):
orderable=False
)
objectchanges = ObjectChange.objects.filter(
changed_object_type=objectchange.changed_object_type,
changed_object_id=objectchange.changed_object_id,
)
next_change = objectchanges.filter(time__gt=objectchange.time).order_by('time').first()
prev_change = objectchanges.filter(time__lt=objectchange.time).order_by('-time').first()
if prev_change:
diff_added = shallow_compare_dict(
prev_change.object_data,
objectchange.object_data,
exclude=['last_updated'],
)
diff_removed = {x: prev_change.object_data.get(x) for x in diff_added}
else:
# No previous change; this is the initial change that added the object
diff_added = diff_removed = objectchange.object_data
return render(request, 'extras/objectchange.html', {
'objectchange': objectchange,
'diff_added': diff_added,
'diff_removed': diff_removed,
'next_change': next_change,
'prev_change': prev_change,
'related_changes_table': related_changes_table,
'related_changes_count': related_changes.count()
})

View File

@@ -1,4 +1,3 @@
import datetime
import hashlib
import hmac

View File

@@ -1,19 +1,21 @@
import json
import logging
import requests
from django_rq import job
from rest_framework.utils.encoders import JSONEncoder
from jinja2.exceptions import TemplateError
from .choices import ObjectChangeActionChoices, WebhookContentTypeChoices
from .choices import ObjectChangeActionChoices
from .webhooks import generate_signature
logger = logging.getLogger('netbox.webhooks_worker')
@job('default')
def process_webhook(webhook, data, model_name, event, timestamp, username, request_id):
"""
Make a POST request to the defined Webhook
"""
payload = {
context = {
'event': dict(ObjectChangeActionChoices)[event].lower(),
'timestamp': timestamp,
'model': model_name,
@@ -21,29 +23,48 @@ def process_webhook(webhook, data, model_name, event, timestamp, username, reque
'request_id': request_id,
'data': data
}
# Build the headers for the HTTP request
headers = {
'Content-Type': webhook.http_content_type,
}
if webhook.additional_headers:
headers.update(webhook.additional_headers)
try:
headers.update(webhook.render_headers(context))
except (TemplateError, ValueError) as e:
logger.error("Error parsing HTTP headers for webhook {}: {}".format(webhook, e))
raise e
# Render the request body
try:
body = webhook.render_body(context)
except TemplateError as e:
logger.error("Error rendering request body for webhook {}: {}".format(webhook, e))
raise e
# Prepare the HTTP request
params = {
'method': 'POST',
'method': webhook.http_method,
'url': webhook.payload_url,
'headers': headers
'headers': headers,
'data': body,
}
logger.info(
"Sending {} request to {} ({} {})".format(
params['method'], params['url'], context['model'], context['event']
)
)
logger.debug(params)
try:
prepared_request = requests.Request(**params).prepare()
except requests.exceptions.RequestException as e:
logger.error("Error forming HTTP request: {}".format(e))
raise e
if webhook.http_content_type == WebhookContentTypeChoices.CONTENTTYPE_JSON:
params.update({'data': json.dumps(payload, cls=JSONEncoder)})
elif webhook.http_content_type == WebhookContentTypeChoices.CONTENTTYPE_FORMDATA:
params.update({'data': payload})
prepared_request = requests.Request(**params).prepare()
# If a secret key is defined, sign the request with a hash of the key and its content
if webhook.secret != '':
# Sign the request with a hash of the secret key and its content.
prepared_request.headers['X-Hook-Signature'] = generate_signature(prepared_request.body, webhook.secret)
# Send the request
with requests.Session() as session:
session.verify = webhook.ssl_verification
if webhook.ca_file_path:
@@ -51,8 +72,10 @@ def process_webhook(webhook, data, model_name, event, timestamp, username, reque
response = session.send(prepared_request)
if 200 <= response.status_code <= 299:
logger.info("Request succeeded; response status {}".format(response.status_code))
return 'Status {} returned, webhook successfully processed.'.format(response.status_code)
else:
logger.warning("Request failed; response status {}: {}".format(response.status_code, response.content))
raise requests.exceptions.RequestException(
"Status {} returned with content '{}', webhook FAILED to process.".format(
response.status_code, response.content

View File

@@ -1,6 +1,7 @@
from django.conf import settings
from django.db.models import Count
from django.shortcuts import get_object_or_404
from django_pglocks import advisory_lock
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
@@ -10,6 +11,7 @@ from extras.api.views import CustomFieldModelViewSet
from ipam import filters
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
from utilities.api import FieldChoicesViewSet, ModelViewSet
from utilities.constants import ADVISORY_LOCK_KEYS
from utilities.utils import get_subquery
from . import serializers
@@ -86,9 +88,13 @@ class PrefixViewSet(CustomFieldModelViewSet):
filterset_class = filters.PrefixFilterSet
@action(detail=True, url_path='available-prefixes', methods=['get', 'post'])
@advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes'])
def available_prefixes(self, request, pk=None):
"""
A convenience method for returning available child prefixes within a parent.
The advisory lock decorator uses a PostgreSQL advisory lock to prevent this API from being
invoked in parallel, which results in a race condition where multiple insertions can occur.
"""
prefix = get_object_or_404(Prefix, pk=pk)
available_prefixes = prefix.get_available_prefixes()
@@ -180,11 +186,15 @@ class PrefixViewSet(CustomFieldModelViewSet):
return Response(serializer.data)
@action(detail=True, url_path='available-ips', methods=['get', 'post'])
@advisory_lock(ADVISORY_LOCK_KEYS['available-ips'])
def available_ips(self, request, pk=None):
"""
A convenience method for returning available IP addresses within a prefix. By default, the number of IPs
returned will be equivalent to PAGINATE_COUNT. An arbitrary limit (up to MAX_PAGE_SIZE, if set) may be passed,
however results will not be paginated.
The advisory lock decorator uses a PostgreSQL advisory lock to prevent this API from being
invoked in parallel, which results in a race condition where multiple insertions can occur.
"""
prefix = get_object_or_404(Prefix, pk=pk)

View File

@@ -8,7 +8,8 @@ from dcim.models import Device, Interface, Region, Site
from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
from tenancy.filters import TenancyFilterSet
from utilities.filters import (
MultiValueCharFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter,
BaseFilterSet, MultiValueCharFilter, MultiValueNumberFilter, NameSlugSearchFilterSet,
NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter,
)
from virtualization.models import VirtualMachine
from .choices import *
@@ -28,7 +29,7 @@ __all__ = (
)
class VRFFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
class VRFFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
id__in = NumericInFilter(
field_name='id',
lookup_expr='in'
@@ -53,7 +54,7 @@ class VRFFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterS
fields = ['name', 'rd', 'enforce_unique']
class RIRFilterSet(NameSlugSearchFilterSet):
class RIRFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
id__in = NumericInFilter(
field_name='id',
lookup_expr='in'
@@ -64,7 +65,7 @@ class RIRFilterSet(NameSlugSearchFilterSet):
fields = ['name', 'slug', 'is_private']
class AggregateFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet):
class AggregateFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
id__in = NumericInFilter(
field_name='id',
lookup_expr='in'
@@ -114,7 +115,7 @@ class AggregateFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet):
return queryset.none()
class RoleFilterSet(NameSlugSearchFilterSet):
class RoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@@ -125,7 +126,7 @@ class RoleFilterSet(NameSlugSearchFilterSet):
fields = ['id', 'name', 'slug']
class PrefixFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
class PrefixFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
id__in = NumericInFilter(
field_name='id',
lookup_expr='in'
@@ -166,12 +167,14 @@ class PrefixFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilt
)
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region__in',
field_name='site__region',
lookup_expr='in',
label='Region (ID)',
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region__in',
field_name='site__region',
lookup_expr='in',
to_field_name='slug',
label='Region (slug)',
)
@@ -273,7 +276,7 @@ class PrefixFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilt
return queryset.filter(prefix__net_mask_length=value)
class IPAddressFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
id__in = NumericInFilter(
field_name='id',
lookup_expr='in'
@@ -397,15 +400,17 @@ class IPAddressFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedF
return queryset.exclude(interface__isnull=value)
class VLANGroupFilterSet(NameSlugSearchFilterSet):
class VLANGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region__in',
field_name='site__region',
lookup_expr='in',
label='Region (ID)',
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region__in',
field_name='site__region',
lookup_expr='in',
to_field_name='slug',
label='Region (slug)',
)
@@ -425,7 +430,7 @@ class VLANGroupFilterSet(NameSlugSearchFilterSet):
fields = ['id', 'name', 'slug']
class VLANFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
class VLANFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
id__in = NumericInFilter(
field_name='id',
lookup_expr='in'
@@ -436,12 +441,14 @@ class VLANFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilter
)
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region__in',
field_name='site__region',
lookup_expr='in',
label='Region (ID)',
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region__in',
field_name='site__region',
lookup_expr='in',
to_field_name='slug',
label='Region (slug)',
)
@@ -496,7 +503,7 @@ class VLANFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilter
return queryset.filter(qs_filter)
class ServiceFilterSet(CreatedUpdatedFilterSet):
class ServiceFilterSet(BaseFilterSet, CreatedUpdatedFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',

View File

@@ -276,6 +276,7 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(),
required=False,
label='VRF',
widget=APISelect(
api_url="/api/ipam/vrfs/",
)

View File

@@ -154,10 +154,24 @@ class NetHostContained(Lookup):
return 'CAST(HOST(%s) AS INET) << %s' % (lhs, rhs), params
#
# Transforms
#
class NetMaskLength(Transform):
lookup_name = 'net_mask_length'
function = 'MASKLEN'
lookup_name = 'net_mask_length'
@property
def output_field(self):
return IntegerField()
class Host(Transform):
function = 'HOST'
lookup_name = 'host'
class Inet(Transform):
function = 'INET'
lookup_name = 'inet'

View File

@@ -1,5 +1,6 @@
from django.db import models
from django.db.models.expressions import RawSQL
from ipam.lookups import Host, Inet
class IPAddressManager(models.Manager):
@@ -13,4 +14,4 @@ class IPAddressManager(models.Manager):
IP address as a /32 or /128.
"""
qs = super().get_queryset()
return qs.annotate(host=RawSQL('INET(HOST(ipam_ipaddress.address))', [])).order_by('family', 'host')
return qs.order_by('family', Inet(Host('address')))

View File

@@ -26,7 +26,7 @@ RIR_UTILIZATION = """
"""
RIR_ACTIONS = """
<a href="{% url 'ipam:rir_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
<a href="{% url 'ipam:rir_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
<i class="fa fa-history"></i>
</a>
{% if perms.ipam.change_rir %}
@@ -48,7 +48,7 @@ ROLE_VLAN_COUNT = """
"""
ROLE_ACTIONS = """
<a href="{% url 'ipam:role_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
<a href="{% url 'ipam:role_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
<i class="fa fa-history"></i>
</a>
{% if perms.ipam.change_role %}
@@ -145,7 +145,7 @@ VLAN_ROLE_LINK = """
"""
VLANGROUP_ACTIONS = """
<a href="{% url 'ipam:vlangroup_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Changelog">
<a href="{% url 'ipam:vlangroup_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Change log">
<i class="fa fa-history"></i>
</a>
{% with next_vid=record.get_next_available_vid %}
@@ -385,7 +385,7 @@ class InterfaceIPAddressTable(BaseTable):
"""
List IP addresses assigned to a specific Interface.
"""
address = tables.TemplateColumn(IPADDRESS_ASSIGN_LINK, verbose_name='IP Address')
address = tables.LinkColumn(verbose_name='IP Address')
vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
status = tables.TemplateColumn(STATUS_LABEL)
tenant = tables.TemplateColumn(template_code=TENANT_LINK)

View File

@@ -5,6 +5,7 @@ from ipam.choices import *
from ipam.filters import *
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
from virtualization.models import Cluster, ClusterType, VirtualMachine
from tenancy.models import Tenant, TenantGroup
class VRFTestCase(TestCase):
@@ -14,13 +15,27 @@ class VRFTestCase(TestCase):
@classmethod
def setUpTestData(cls):
tenant_groups = (
TenantGroup(name='Tenant group 1', slug='tenant-group-1'),
TenantGroup(name='Tenant group 2', slug='tenant-group-2'),
TenantGroup(name='Tenant group 3', slug='tenant-group-3'),
)
TenantGroup.objects.bulk_create(tenant_groups)
tenants = (
Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]),
Tenant(name='Tenant 2', slug='tenant-2', group=tenant_groups[1]),
Tenant(name='Tenant 3', slug='tenant-3', group=tenant_groups[2]),
)
Tenant.objects.bulk_create(tenants)
vrfs = (
VRF(name='VRF 1', rd='65000:100', enforce_unique=False),
VRF(name='VRF 2', rd='65000:200', enforce_unique=False),
VRF(name='VRF 3', rd='65000:300', enforce_unique=False),
VRF(name='VRF 4', rd='65000:400', enforce_unique=True),
VRF(name='VRF 5', rd='65000:500', enforce_unique=True),
VRF(name='VRF 6', rd='65000:600', enforce_unique=True),
VRF(name='VRF 1', rd='65000:100', tenant=tenants[0], enforce_unique=False),
VRF(name='VRF 2', rd='65000:200', tenant=tenants[0], enforce_unique=False),
VRF(name='VRF 3', rd='65000:300', tenant=tenants[1], enforce_unique=False),
VRF(name='VRF 4', rd='65000:400', tenant=tenants[1], enforce_unique=True),
VRF(name='VRF 5', rd='65000:500', tenant=tenants[2], enforce_unique=True),
VRF(name='VRF 6', rd='65000:600', tenant=tenants[2], enforce_unique=True),
)
VRF.objects.bulk_create(vrfs)
@@ -43,6 +58,20 @@ class VRFTestCase(TestCase):
params = {'id__in': ','.join([str(id) for id in id_list])}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_tenant(self):
tenants = Tenant.objects.all()[:2]
params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'tenant': [tenants[0].slug, tenants[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_tenant_group(self):
tenant_groups = TenantGroup.objects.all()[:2]
params = {'tenant_group_id': [tenant_groups[0].pk, tenant_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
class RIRTestCase(TestCase):
queryset = RIR.objects.all()
@@ -198,15 +227,29 @@ class PrefixTestCase(TestCase):
)
Role.objects.bulk_create(roles)
tenant_groups = (
TenantGroup(name='Tenant group 1', slug='tenant-group-1'),
TenantGroup(name='Tenant group 2', slug='tenant-group-2'),
TenantGroup(name='Tenant group 3', slug='tenant-group-3'),
)
TenantGroup.objects.bulk_create(tenant_groups)
tenants = (
Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]),
Tenant(name='Tenant 2', slug='tenant-2', group=tenant_groups[1]),
Tenant(name='Tenant 3', slug='tenant-3', group=tenant_groups[2]),
)
Tenant.objects.bulk_create(tenants)
prefixes = (
Prefix(family=4, prefix='10.0.0.0/24', site=None, vrf=None, vlan=None, role=None, is_pool=True),
Prefix(family=4, prefix='10.0.1.0/24', site=sites[0], vrf=vrfs[0], vlan=vlans[0], role=roles[0]),
Prefix(family=4, prefix='10.0.2.0/24', site=sites[1], vrf=vrfs[1], vlan=vlans[1], role=roles[1], status=PrefixStatusChoices.STATUS_DEPRECATED),
Prefix(family=4, prefix='10.0.3.0/24', site=sites[2], vrf=vrfs[2], vlan=vlans[2], role=roles[2], status=PrefixStatusChoices.STATUS_RESERVED),
Prefix(family=6, prefix='2001:db8::/64', site=None, vrf=None, vlan=None, role=None, is_pool=True),
Prefix(family=6, prefix='2001:db8:0:1::/64', site=sites[0], vrf=vrfs[0], vlan=vlans[0], role=roles[0]),
Prefix(family=6, prefix='2001:db8:0:2::/64', site=sites[1], vrf=vrfs[1], vlan=vlans[1], role=roles[1], status=PrefixStatusChoices.STATUS_DEPRECATED),
Prefix(family=6, prefix='2001:db8:0:3::/64', site=sites[2], vrf=vrfs[2], vlan=vlans[2], role=roles[2], status=PrefixStatusChoices.STATUS_RESERVED),
Prefix(family=4, prefix='10.0.0.0/24', tenant=None, site=None, vrf=None, vlan=None, role=None, is_pool=True),
Prefix(family=4, prefix='10.0.1.0/24', tenant=tenants[0], site=sites[0], vrf=vrfs[0], vlan=vlans[0], role=roles[0]),
Prefix(family=4, prefix='10.0.2.0/24', tenant=tenants[1], site=sites[1], vrf=vrfs[1], vlan=vlans[1], role=roles[1], status=PrefixStatusChoices.STATUS_DEPRECATED),
Prefix(family=4, prefix='10.0.3.0/24', tenant=tenants[2], site=sites[2], vrf=vrfs[2], vlan=vlans[2], role=roles[2], status=PrefixStatusChoices.STATUS_RESERVED),
Prefix(family=6, prefix='2001:db8::/64', tenant=None, site=None, vrf=None, vlan=None, role=None, is_pool=True),
Prefix(family=6, prefix='2001:db8:0:1::/64', tenant=tenants[0], site=sites[0], vrf=vrfs[0], vlan=vlans[0], role=roles[0]),
Prefix(family=6, prefix='2001:db8:0:2::/64', tenant=tenants[1], site=sites[1], vrf=vrfs[1], vlan=vlans[1], role=roles[1], status=PrefixStatusChoices.STATUS_DEPRECATED),
Prefix(family=6, prefix='2001:db8:0:3::/64', tenant=tenants[2], site=sites[2], vrf=vrfs[2], vlan=vlans[2], role=roles[2], status=PrefixStatusChoices.STATUS_RESERVED),
Prefix(family=4, prefix='10.0.0.0/16'),
Prefix(family=6, prefix='2001:db8::/32'),
)
@@ -285,6 +328,20 @@ class PrefixTestCase(TestCase):
params = {'status': [PrefixStatusChoices.STATUS_DEPRECATED, PrefixStatusChoices.STATUS_RESERVED]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_tenant(self):
tenants = Tenant.objects.all()[:2]
params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'tenant': [tenants[0].slug, tenants[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_tenant_group(self):
tenant_groups = TenantGroup.objects.all()[:2]
params = {'tenant_group_id': [tenant_groups[0].pk, tenant_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
class IPAddressTestCase(TestCase):
queryset = IPAddress.objects.all()
@@ -332,17 +389,31 @@ class IPAddressTestCase(TestCase):
)
Interface.objects.bulk_create(interfaces)
tenant_groups = (
TenantGroup(name='Tenant group 1', slug='tenant-group-1'),
TenantGroup(name='Tenant group 2', slug='tenant-group-2'),
TenantGroup(name='Tenant group 3', slug='tenant-group-3'),
)
TenantGroup.objects.bulk_create(tenant_groups)
tenants = (
Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]),
Tenant(name='Tenant 2', slug='tenant-2', group=tenant_groups[1]),
Tenant(name='Tenant 3', slug='tenant-3', group=tenant_groups[2]),
)
Tenant.objects.bulk_create(tenants)
ipaddresses = (
IPAddress(family=4, address='10.0.0.1/24', vrf=None, interface=None, status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-a'),
IPAddress(family=4, address='10.0.0.2/24', vrf=vrfs[0], interface=interfaces[0], status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-b'),
IPAddress(family=4, address='10.0.0.3/24', vrf=vrfs[1], interface=interfaces[1], status=IPAddressStatusChoices.STATUS_RESERVED, role=IPAddressRoleChoices.ROLE_VIP, dns_name='ipaddress-c'),
IPAddress(family=4, address='10.0.0.4/24', vrf=vrfs[2], interface=interfaces[2], status=IPAddressStatusChoices.STATUS_DEPRECATED, role=IPAddressRoleChoices.ROLE_SECONDARY, dns_name='ipaddress-d'),
IPAddress(family=4, address='10.0.0.1/25', vrf=None, interface=None, status=IPAddressStatusChoices.STATUS_ACTIVE),
IPAddress(family=6, address='2001:db8::1/64', vrf=None, interface=None, status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-a'),
IPAddress(family=6, address='2001:db8::2/64', vrf=vrfs[0], interface=interfaces[3], status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-b'),
IPAddress(family=6, address='2001:db8::3/64', vrf=vrfs[1], interface=interfaces[4], status=IPAddressStatusChoices.STATUS_RESERVED, role=IPAddressRoleChoices.ROLE_VIP, dns_name='ipaddress-c'),
IPAddress(family=6, address='2001:db8::4/64', vrf=vrfs[2], interface=interfaces[5], status=IPAddressStatusChoices.STATUS_DEPRECATED, role=IPAddressRoleChoices.ROLE_SECONDARY, dns_name='ipaddress-d'),
IPAddress(family=6, address='2001:db8::1/65', vrf=None, interface=None, status=IPAddressStatusChoices.STATUS_ACTIVE),
IPAddress(family=4, address='10.0.0.1/24', tenant=None, vrf=None, interface=None, status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-a'),
IPAddress(family=4, address='10.0.0.2/24', tenant=tenants[0], vrf=vrfs[0], interface=interfaces[0], status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-b'),
IPAddress(family=4, address='10.0.0.3/24', tenant=tenants[1], vrf=vrfs[1], interface=interfaces[1], status=IPAddressStatusChoices.STATUS_RESERVED, role=IPAddressRoleChoices.ROLE_VIP, dns_name='ipaddress-c'),
IPAddress(family=4, address='10.0.0.4/24', tenant=tenants[2], vrf=vrfs[2], interface=interfaces[2], status=IPAddressStatusChoices.STATUS_DEPRECATED, role=IPAddressRoleChoices.ROLE_SECONDARY, dns_name='ipaddress-d'),
IPAddress(family=4, address='10.0.0.1/25', tenant=None, vrf=None, interface=None, status=IPAddressStatusChoices.STATUS_ACTIVE),
IPAddress(family=6, address='2001:db8::1/64', tenant=None, vrf=None, interface=None, status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-a'),
IPAddress(family=6, address='2001:db8::2/64', tenant=tenants[0], vrf=vrfs[0], interface=interfaces[3], status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-b'),
IPAddress(family=6, address='2001:db8::3/64', tenant=tenants[1], vrf=vrfs[1], interface=interfaces[4], status=IPAddressStatusChoices.STATUS_RESERVED, role=IPAddressRoleChoices.ROLE_VIP, dns_name='ipaddress-c'),
IPAddress(family=6, address='2001:db8::4/64', tenant=tenants[2], vrf=vrfs[2], interface=interfaces[5], status=IPAddressStatusChoices.STATUS_DEPRECATED, role=IPAddressRoleChoices.ROLE_SECONDARY, dns_name='ipaddress-d'),
IPAddress(family=6, address='2001:db8::1/65', tenant=None, vrf=None, interface=None, status=IPAddressStatusChoices.STATUS_ACTIVE),
)
IPAddress.objects.bulk_create(ipaddresses)
@@ -427,6 +498,20 @@ class IPAddressTestCase(TestCase):
params = {'role': [IPAddressRoleChoices.ROLE_SECONDARY, IPAddressRoleChoices.ROLE_VIP]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_tenant(self):
tenants = Tenant.objects.all()[:2]
params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'tenant': [tenants[0].slug, tenants[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_tenant_group(self):
tenant_groups = TenantGroup.objects.all()[:2]
params = {'tenant_group_id': [tenant_groups[0].pk, tenant_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
class VLANGroupTestCase(TestCase):
queryset = VLANGroup.objects.all()
@@ -524,13 +609,27 @@ class VLANTestCase(TestCase):
)
VLANGroup.objects.bulk_create(groups)
tenant_groups = (
TenantGroup(name='Tenant group 1', slug='tenant-group-1'),
TenantGroup(name='Tenant group 2', slug='tenant-group-2'),
TenantGroup(name='Tenant group 3', slug='tenant-group-3'),
)
TenantGroup.objects.bulk_create(tenant_groups)
tenants = (
Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]),
Tenant(name='Tenant 2', slug='tenant-2', group=tenant_groups[1]),
Tenant(name='Tenant 3', slug='tenant-3', group=tenant_groups[2]),
)
Tenant.objects.bulk_create(tenants)
vlans = (
VLAN(vid=101, name='VLAN 101', site=sites[0], group=groups[0], role=roles[0], status=VLANStatusChoices.STATUS_ACTIVE),
VLAN(vid=102, name='VLAN 102', site=sites[0], group=groups[0], role=roles[0], status=VLANStatusChoices.STATUS_ACTIVE),
VLAN(vid=201, name='VLAN 201', site=sites[1], group=groups[1], role=roles[1], status=VLANStatusChoices.STATUS_DEPRECATED),
VLAN(vid=202, name='VLAN 202', site=sites[1], group=groups[1], role=roles[1], status=VLANStatusChoices.STATUS_DEPRECATED),
VLAN(vid=301, name='VLAN 301', site=sites[2], group=groups[2], role=roles[2], status=VLANStatusChoices.STATUS_RESERVED),
VLAN(vid=302, name='VLAN 302', site=sites[2], group=groups[2], role=roles[2], status=VLANStatusChoices.STATUS_RESERVED),
VLAN(vid=101, name='VLAN 101', site=sites[0], group=groups[0], role=roles[0], tenant=tenants[0], status=VLANStatusChoices.STATUS_ACTIVE),
VLAN(vid=102, name='VLAN 102', site=sites[0], group=groups[0], role=roles[0], tenant=tenants[0], status=VLANStatusChoices.STATUS_ACTIVE),
VLAN(vid=201, name='VLAN 201', site=sites[1], group=groups[1], role=roles[1], tenant=tenants[1], status=VLANStatusChoices.STATUS_DEPRECATED),
VLAN(vid=202, name='VLAN 202', site=sites[1], group=groups[1], role=roles[1], tenant=tenants[1], status=VLANStatusChoices.STATUS_DEPRECATED),
VLAN(vid=301, name='VLAN 301', site=sites[2], group=groups[2], role=roles[2], tenant=tenants[2], status=VLANStatusChoices.STATUS_RESERVED),
VLAN(vid=302, name='VLAN 302', site=sites[2], group=groups[2], role=roles[2], tenant=tenants[2], status=VLANStatusChoices.STATUS_RESERVED),
)
VLAN.objects.bulk_create(vlans)
@@ -579,6 +678,20 @@ class VLANTestCase(TestCase):
params = {'status': [VLANStatusChoices.STATUS_ACTIVE, VLANStatusChoices.STATUS_DEPRECATED]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_tenant(self):
tenants = Tenant.objects.all()[:2]
params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'tenant': [tenants[0].slug, tenants[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_tenant_group(self):
tenant_groups = TenantGroup.objects.all()[:2]
params = {'tenant_group_id': [tenant_groups[0].pk, tenant_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
class ServiceTestCase(TestCase):
queryset = Service.objects.all()

View File

@@ -118,7 +118,6 @@ class VRFListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.VRFFilterSet
filterset_form = forms.VRFFilterForm
table = tables.VRFTable
template_name = 'ipam/vrf_list.html'
class VRFView(PermissionRequiredMixin, View):
@@ -293,7 +292,6 @@ class AggregateListView(PermissionRequiredMixin, ObjectListView):
queryset = Aggregate.objects.prefetch_related('rir').annotate(
child_count=RawSQL('SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix', ())
)
filterset = filters.AggregateFilterSet
filterset_form = forms.AggregateFilterForm
table = tables.AggregateDetailTable
@@ -411,7 +409,6 @@ class RoleListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'ipam.view_role'
queryset = Role.objects.all()
table = tables.RoleTable
template_name = 'ipam/role_list.html'
class RoleCreateView(PermissionRequiredMixin, ObjectEditView):
@@ -644,7 +641,6 @@ class IPAddressListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.IPAddressFilterSet
filterset_form = forms.IPAddressFilterForm
table = tables.IPAddressDetailTable
template_name = 'ipam/ipaddress_list.html'
class IPAddressView(PermissionRequiredMixin, View):
@@ -817,7 +813,6 @@ class VLANGroupListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.VLANGroupFilterSet
filterset_form = forms.VLANGroupFilterForm
table = tables.VLANGroupTable
template_name = 'ipam/vlangroup_list.html'
class VLANGroupCreateView(PermissionRequiredMixin, ObjectEditView):
@@ -893,7 +888,6 @@ class VLANListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.VLANFilterSet
filterset_form = forms.VLANFilterForm
table = tables.VLANDetailTable
template_name = 'ipam/vlan_list.html'
class VLANView(PermissionRequiredMixin, View):
@@ -989,7 +983,7 @@ class ServiceListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.ServiceFilterSet
filterset_form = forms.ServiceFilterForm
table = tables.ServiceTable
template_name = 'ipam/service_list.html'
action_buttons = ('export',)
class ServiceView(PermissionRequiredMixin, View):

View File

@@ -0,0 +1,2 @@
*
!.gitignore

View File

@@ -12,7 +12,7 @@ from django.core.exceptions import ImproperlyConfigured
# Environment setup
#
VERSION = '2.7.6'
VERSION = '2.7.9'
# Hostname
HOSTNAME = platform.node()
@@ -506,6 +506,7 @@ REST_FRAMEWORK = {
SWAGGER_SETTINGS = {
'DEFAULT_AUTO_SCHEMA_CLASS': 'utilities.custom_inspectors.NetBoxSwaggerAutoSchema',
'DEFAULT_FIELD_INSPECTORS': [
'utilities.custom_inspectors.JSONFieldInspector',
'utilities.custom_inspectors.NullableBooleanFieldInspector',
'utilities.custom_inspectors.CustomChoiceFieldInspector',
'utilities.custom_inspectors.TagListFieldInspector',

View File

@@ -1,6 +1,6 @@
from collections import OrderedDict
from django.db.models import Count, F, OuterRef, Subquery
from django.db.models import Count, F
from django.shortcuts import render
from django.views.generic import View
from rest_framework.response import Response
@@ -8,7 +8,7 @@ from rest_framework.reverse import reverse
from rest_framework.views import APIView
from circuits.filters import CircuitFilterSet, ProviderFilterSet
from circuits.models import Circuit, CircuitTermination, Provider
from circuits.models import Circuit, Provider
from circuits.tables import CircuitTable, ProviderTable
from dcim.filters import (
CableFilterSet, DeviceFilterSet, DeviceTypeFilterSet, PowerFeedFilterSet, RackFilterSet, RackGroupFilterSet, SiteFilterSet,
@@ -50,15 +50,7 @@ SEARCH_TYPES = OrderedDict((
'permission': 'circuits.view_circuit',
'queryset': Circuit.objects.prefetch_related(
'type', 'provider', 'tenant', 'terminations__site'
).annotate(
# Annotate A/Z terminations
a_side=Subquery(
CircuitTermination.objects.filter(circuit=OuterRef('pk')).filter(term_side='A').values('site__name')[:1]
),
z_side=Subquery(
CircuitTermination.objects.filter(circuit=OuterRef('pk')).filter(term_side='Z').values('site__name')[:1]
),
),
).annotate_sites(),
'filterset': CircuitFilterSet,
'table': CircuitTable,
'url': 'circuits:circuit_list',

View File

@@ -179,25 +179,9 @@ nav ul.pagination {
/* Racks */
div.rack_header {
margin-left: 36px;
margin-left: 32px;
text-align: center;
width: 230px;
}
ul.rack_legend {
float: left;
list-style-type: none;
margin-right: 6px;
padding: 0;
width: 30px;
}
ul.rack_legend li {
color: #c0c0c0;
display: block;
font-size: 10px;
height: 20px;
overflow: hidden;
padding: 5px 0;
text-align: right;
width: 220px;
}
/* Devices */

View File

@@ -14,7 +14,7 @@ text {
background-color: #f0f0f0;
fill: none;
stroke: black;
stroke-width: 3px;
stroke-width: 2px;
}
.slot {
fill: #f7f7f7;
@@ -56,7 +56,6 @@ text {
.blocked:hover+.add-device {
fill: none;
}
.unit {
margin: 0;
padding: 5px 0px;
@@ -65,3 +64,6 @@ text {
font-size: 10px;
font-family: "Helvetica Neue",Helvetica,Arial,sans-serif;
}
.hidden {
visibility: hidden;
}

View File

@@ -42,17 +42,23 @@ $(document).ready(function() {
return s.substring(0, num_chars); // Trim to first num_chars chars
}
var slug_field = $('#id_slug');
slug_field.change(function() {
$(this).attr('_changed', true);
});
if (slug_field) {
var slug_source = $('#id_' + slug_field.attr('slug-source'));
var slug_length = slug_field.attr('maxlength');
if (slug_field.val()) {
slug_field.attr('_changed', true);
}
slug_field.change(function() {
$(this).attr('_changed', true);
});
slug_source.on('keyup change', function() {
if (slug_field && !slug_field.attr('_changed')) {
slug_field.val(slugify($(this).val(), (slug_length ? slug_length : 50)));
}
})
});
$('button.reslugify').click(function() {
slug_field.val(slugify(slug_source.val(), (slug_length ? slug_length : 50)));
});
}
// Bulk edit nullification
@@ -190,15 +196,18 @@ $(document).ready(function() {
$.each(element.attributes, function(index, attr){
if (attr.name.includes("data-additional-query-param-")){
var param_name = attr.name.split("data-additional-query-param-")[1];
if (param_name in parameters) {
if (Array.isArray(parameters[param_name])) {
parameters[param_name].push(attr.value)
$.each($.parseJSON(attr.value), function(index, value) {
if (param_name in parameters) {
if (Array.isArray(parameters[param_name])) {
parameters[param_name].push(value);
} else {
parameters[param_name] = [parameters[param_name], value];
}
} else {
parameters[param_name] = [parameters[param_name], attr.value]
parameters[param_name] = value;
}
} else {
parameters[param_name] = attr.value;
}
});
}
});

View File

@@ -0,0 +1,13 @@
// Toggle the display of device images within an SVG rack elevation
$('button.toggle-images').click(function() {
var selected = $(this).attr('selected');
var rack_elevation = $(".rack_elevation");
if (selected) {
$('.device-image', rack_elevation.contents()).addClass('hidden');
} else {
$('.device-image', rack_elevation.contents()).removeClass('hidden');
}
$(this).attr('selected', !selected);
$(this).children('span').toggleClass('glyphicon-check glyphicon-unchecked');
return false;
});

View File

@@ -3,7 +3,7 @@ from django.db.models import Q
from dcim.models import Device
from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter
from utilities.filters import BaseFilterSet, NameSlugSearchFilterSet, NumericInFilter, TagFilter
from .models import Secret, SecretRole
@@ -13,14 +13,14 @@ __all__ = (
)
class SecretRoleFilterSet(NameSlugSearchFilterSet):
class SecretRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
class Meta:
model = SecretRole
fields = ['id', 'name', 'slug']
class SecretFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet):
class SecretFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
id__in = NumericInFilter(
field_name='id',
lookup_expr='in'

View File

@@ -185,7 +185,7 @@ class SecretFilterForm(BootstrapMixin, CustomFieldFilterForm):
role = DynamicModelMultipleChoiceField(
queryset=SecretRole.objects.all(),
to_field_name='slug',
required=True,
required=False,
widget=APISelectMultiple(
api_url="/api/secrets/secret-roles/",
value_field="slug",

View File

@@ -302,8 +302,8 @@ class Secret(ChangeLoggedModel, CustomFieldModel):
Device; Devices may have multiple Secrets associated with them. A name can optionally be defined along with the
ciphertext; this string is stored as plain text in the database.
A Secret can be up to 65,536 bytes (64KB) in length. Each secret string will be padded with random data to a minimum
of 64 bytes during encryption in order to protect short strings from ciphertext analysis.
A Secret can be up to 65,535 bytes (64KB - 1B) in length. Each secret string will be padded with random data to
a minimum of 64 bytes during encryption in order to protect short strings from ciphertext analysis.
"""
device = models.ForeignKey(
to='dcim.Device',
@@ -320,7 +320,7 @@ class Secret(ChangeLoggedModel, CustomFieldModel):
blank=True
)
ciphertext = models.BinaryField(
max_length=65568, # 16B IV + 2B pad length + {62-65550}B padded
max_length=65568, # 128-bit IV + 16-bit pad length + 65535B secret + 15B padding
editable=False
)
hash = models.CharField(
@@ -388,11 +388,7 @@ class Secret(ChangeLoggedModel, CustomFieldModel):
else:
pad_length = 0
# Python 2 compatibility
if sys.version_info[0] < 3:
header = chr(len(s) >> 8) + chr(len(s) % 256)
else:
header = bytes([len(s) >> 8]) + bytes([len(s) % 256])
header = bytes([len(s) >> 8]) + bytes([len(s) % 256])
return header + s + os.urandom(pad_length)

View File

@@ -4,7 +4,7 @@ from utilities.tables import BaseTable, ToggleColumn
from .models import SecretRole, Secret
SECRETROLE_ACTIONS = """
<a href="{% url 'secrets:secretrole_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
<a href="{% url 'secrets:secretrole_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
<i class="fa fa-history"></i>
</a>
{% if perms.secrets.change_secretrole %}

View File

@@ -85,14 +85,19 @@ class UserKeyTestCase(TestCase):
class SecretTestCase(TestCase):
@classmethod
def setUpTestData(cls):
# Generate a random key for encryption/decryption of secrets
cls.secret_key = generate_random_key()
def test_01_encrypt_decrypt(self):
"""
Test basic encryption and decryption functionality using a random master key.
"""
plaintext = string.printable * 2
secret_key = generate_random_key()
s = Secret(plaintext=plaintext)
s.encrypt(secret_key)
s.encrypt(self.secret_key)
# Ensure plaintext is deleted upon encryption
self.assertIsNone(s.plaintext, "Plaintext must be None after encrypting.")
@@ -112,7 +117,7 @@ class SecretTestCase(TestCase):
self.assertFalse(s.validate("Invalid plaintext"), "Invalid plaintext validated against hash")
# Test decryption
s.decrypt(secret_key)
s.decrypt(self.secret_key)
self.assertEqual(plaintext, s.plaintext, "Decrypting Secret returned incorrect plaintext")
def test_02_ciphertext_uniqueness(self):
@@ -120,15 +125,45 @@ class SecretTestCase(TestCase):
Generate 50 Secrets using the same plaintext and check for duplicate IVs or payloads.
"""
plaintext = "1234567890abcdef"
secret_key = generate_random_key()
ivs = []
ciphertexts = []
for i in range(1, 51):
s = Secret(plaintext=plaintext)
s.encrypt(secret_key)
s.encrypt(self.secret_key)
ivs.append(s.ciphertext[0:16])
ciphertexts.append(s.ciphertext[16:32])
duplicate_ivs = [i for i, x in enumerate(ivs) if ivs.count(x) > 1]
self.assertEqual(duplicate_ivs, [], "One or more duplicate IVs found!")
duplicate_ciphertexts = [i for i, x in enumerate(ciphertexts) if ciphertexts.count(x) > 1]
self.assertEqual(duplicate_ciphertexts, [], "One or more duplicate ciphertexts (first blocks) found!")
def test_minimum_length(self):
"""
Test enforcement of the minimum length for ciphertexts.
"""
plaintext = 'A' # One-byte plaintext
secret = Secret(plaintext=plaintext)
secret.encrypt(self.secret_key)
# 16B IV + 2B length + 1B secret + 61B padding = 80 bytes
self.assertEqual(len(secret.ciphertext), 80)
self.assertIsNone(secret.plaintext)
secret.decrypt(self.secret_key)
self.assertEqual(secret.plaintext, plaintext)
def test_maximum_length(self):
"""
Test encrypting a plaintext value of the maximum length.
"""
plaintext = '0123456789abcdef' * 4096
plaintext = plaintext[:65535] # 65,535 chars
secret = Secret(plaintext=plaintext)
secret.encrypt(self.secret_key)
# 16B IV + 2B length + 65535B secret + 15B padding = 65568 bytes
self.assertEqual(len(secret.ciphertext), 65568)
self.assertIsNone(secret.plaintext)
secret.decrypt(self.secret_key)
self.assertEqual(secret.plaintext, plaintext)

View File

@@ -35,7 +35,6 @@ class SecretRoleListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'secrets.view_secretrole'
queryset = SecretRole.objects.annotate(secret_count=Count('secrets'))
table = tables.SecretRoleTable
template_name = 'secrets/secretrole_list.html'
class SecretRoleCreateView(PermissionRequiredMixin, ObjectEditView):
@@ -73,7 +72,7 @@ class SecretListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.SecretFilterSet
filterset_form = forms.SecretFilterForm
table = tables.SecretTable
template_name = 'secrets/secret_list.html'
action_buttons = ('import', 'export')
class SecretView(PermissionRequiredMixin, View):

View File

@@ -49,7 +49,7 @@
</li>
{% if perms.extras.view_objectchange %}
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
<a href="{% url 'circuits:circuit_changelog' pk=circuit.pk %}">Changelog</a>
<a href="{% url 'circuits:circuit_changelog' pk=circuit.pk %}">Change Log</a>
</li>
{% endif %}
</ul>

View File

@@ -1,21 +0,0 @@
{% extends '_base.html' %}
{% load buttons %}
{% block content %}
<div class="pull-right noprint">
{% if perms.circuits.add_circuit %}
{% add_button 'circuits:circuit_add' %}
{% import_button 'circuits:circuit_import' %}
{% endif %}
{% export_button content_type %}
</div>
<h1>{% block title %}Circuits{% endblock %}</h1>
<div class="row">
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_edit_url='circuits:circuit_bulk_edit' bulk_delete_url='circuits:circuit_bulk_delete' %}
</div>
<div class="col-md-3 noprint">
{% include 'inc/search_panel.html' %}
</div>
</div>
{% endblock %}

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