Compare commits

...

359 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
472a45ddec Merge pull request #4167 from netbox-community/develop
Release v2.7.6
2020-02-13 21:50:44 -05:00
Jeremy Stretch
0863145c7f Release v2.7.6 2020-02-13 21:46:03 -05:00
Jeremy Stretch
909323663e Fixes #4166: Fix schema migrations to enforce maximum character length for naturalized fields 2020-02-13 21:41:00 -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
4d50cad6ed Post-release version bump 2020-02-13 15:39:08 -05:00
Jeremy Stretch
120cbb0159 Merge pull request #4165 from netbox-community/develop
Release v2.7.5
2020-02-13 15:36:50 -05:00
Jeremy Stretch
08ce024473 Release v2.7.5 2020-02-13 15:32:09 -05:00
Jeremy Stretch
807c2f048d Changelog for #3984 2020-02-13 15:16:07 -05:00
Jeremy Stretch
fafcdf7def Merge pull request #4161 from dstarner/issue-3984-redis-sentinel-conn
Fixes #3984: Allow for Redis Sentinel Connection Configuration
2020-02-13 15:14:42 -05:00
Jeremy Stretch
92fab048d1 Add tests for naturalization functions 2020-02-13 15:13:43 -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
Jeremy Stretch
35498c17d7 Updated the style guide 2020-02-13 11:04:07 -05:00
Jeremy Stretch
874e59b01a Closes #4060: Move secrets panel on home page to righthand column 2020-02-13 10:30:12 -05:00
Dan Starner
72f0e31b84 fixed extraneous linting error 2020-02-13 10:27:56 -05:00
Dan Starner
ba9a2956a8 documentation on redis sentinel 2020-02-13 10:26:03 -05:00
Dan Starner
3538eeda14 allow for redis sentinel connection 2020-02-13 10:26:03 -05:00
Jeremy Stretch
0c89534bfb Closes #4160: Link to full database configuration parameters in configuration docs 2020-02-13 10:08:10 -05:00
Jeremy Stretch
47b15aacef Changelog for #4159 2020-02-13 09:48:12 -05:00
Jeremy Stretch
3e0ab79977 Merge pull request #4162 from netbox-community/4159-caching_redis_using_wrong_key
Fixes: #4159 - Corrects settings.py to use CACHING_REDIS
2020-02-13 09:46:41 -05:00
Jeremy Stretch
344fa72357 renaturalize command should not skip any values 2020-02-13 09:33:01 -05:00
Daniel Sheppard
617fc7659f Fixes: #4159 - Corrects settings.py to use CACHING_REDIS instead of WEBHOOKS_REDIS 2020-02-13 08:26:47 -06:00
Jeremy Stretch
0d91b6b74b Merge pull request #4102 from kobayashi/4093-vm-status-choice
Fixes #4093: Additional status choices for vms
2020-02-12 22:58:43 -05:00
kobayashi
335343642b additional status choices for vms 2020-02-12 22:43:40 -05:00
kobayashi
bc7f5fb33a Modify offline status color for vm 2020-02-12 22:19:17 -05:00
kobayashi
ca56fc709a Fixes #4093: Add decommissioning for vms 2020-02-12 22:19:17 -05:00
Jeremy Stretch
a08ee68033 Exempt pull requests from stalebot 2020-02-12 21:56:23 -05:00
Jeremy Stretch
0d57cb0033 Changelog & docs for #3766 2020-02-12 16:54:40 -05:00
Jeremy Stretch
53804d39bb Merge pull request #4142 from netbox-community/3766-enable_widgets_in_script_fields
Fixes: #3766 - Enable widget usage in scripts
2020-02-12 16:48:08 -05:00
Jeremy Stretch
1e221cd9bb Update changelog 2020-02-12 16:28:19 -05:00
Jeremy Stretch
0c942f18c1 Merge pull request #4068 from netbox-community/4034-prefix_ordering_tests
Fixes: #4034 - Adds Prefix & IPAddress Ordering Tests
2020-02-12 16:27:02 -05:00
Jeremy Stretch
00d32f0a7d Merge pull request #4156 from netbox-community/4153-renaturalize-command
Closes #4153: Add a "renaturalize" management command
2020-02-12 16:20:52 -05:00
Jeremy Stretch
df3fef8bb1 Merge pull request #4154 from netbox-community/4138-device_bays_in_rack_elevations
Fixes: #4138 - Add Device Bay count to rack elevation
2020-02-12 16:19:57 -05:00
Jeremy Stretch
5cc24c055b Tweak docs link for send_mail() to reference stable; formatting 2020-02-12 16:18:08 -05:00
Jeremy Stretch
4064c32a7f Fix nav menu link for configcontext_add 2020-02-12 16:16:23 -05:00
Joshua Corrick
6d7c5d51fe Add email testing example (#4152)
* Add email testing example

Includes an example provided by  Jeremy

* Updated with suggestions

Co-authored-by: Jeremy Stretch <jeremy.stretch@networktocode.com>
2020-02-12 16:15:05 -05:00
Jeremy Stretch
64c0059dd8 Merge pull request #4155 from hSaria/4150-yaml-nested-tags
Fixes #4150: Replace OrderedDict with Dict when rendering YAML
2020-02-12 16:13:32 -05:00
Jeremy Stretch
d0ece2e48d Merge pull request #4144 from netbox-community/3986-rack_positions_svg
Fixes: #3986 - Convert rack units to part of SVG rendered document
2020-02-12 16:06:05 -05:00
Jeremy Stretch
139f18b2e5 Closes #4153: Add a management command to manually renaturalize applicable objects (from #3799) 2020-02-12 15:59:37 -05:00
Saria Hajjar
8eea0331bf Fixes #4150: Replace OrderedDict with Dict when rendering YAML 2020-02-12 19:59:04 +00:00
Daniel Sheppard
62d6e02d6b Modify _draw_elevations
* Add legend_width argument, variable & constant
* Applied legend_width variable where required
* Removed U prefix
2020-02-12 13:15:29 -06:00
Jeremy Stretch
a8601bb1fd Merge pull request #4151 from netbox-community/4146-secretrole-enforcement
Fixes #4146: Fix SecretRole permissions enforcement
2020-02-12 13:51:44 -05:00
Daniel Sheppard
fe452735be Add Device Bay count to rack elevation names 2020-02-12 12:48:13 -06:00
Jeremy Stretch
3b1128f8f3 Establish standard test cases for all models 2020-02-12 13:25:56 -05:00
Daniel Sheppard
0402323ef9 Fixes: #4130
Corrects service name for the RQ worker service in docs/installation/upgrading.md
2020-02-12 11:26:40 -06:00
Jeremy Stretch
5bf85597ed Fixes #4146: Fix SecretRole permissions enforcement 2020-02-12 11:13:32 -05:00
Jeremy Stretch
e4b910fe87 Fixes #4148: Remove dead link to topology maps docs page 2020-02-12 09:44:30 -05:00
Jeremy Stretch
24db573764 Merge pull request #3997 from hSaria/3995-navbar-overflow
Fixes #3995: Navbar scroll when overflowing
2020-02-12 09:41:48 -05:00
Jeremy Stretch
5befa533c6 Merge branch 'develop' into 3995-navbar-overflow 2020-02-12 09:40:31 -05:00
Dan Sheppard
15bc731f61 Convert rack units to part of SVG rendered document 2020-02-11 23:31:51 -06:00
Dan Sheppard
8fb4988fa1 Fix typo in docstring 2020-02-11 21:01:43 -06:00
Daniel Sheppard
ab378ed218 Fixes: #4130
Corrects service name for the RQ worker service in docs/installation/upgrading.md
2020-02-11 20:39:50 -06:00
Daniel Sheppard
56bb053146 Fix test error 2020-02-11 20:27:02 -06:00
Daniel Sheppard
3c3cca8ec1 Enable widget usage (APISelect, APIMultipleSelect, StaticSelect2, etc) in scripts 2020-02-11 20:12:02 -06:00
Dan Sheppard
908586c93a Fix PEP8 errors 2020-02-11 18:21:58 -06: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
Jeremy Stretch
2ab382eec5 Fixes #4137: Disable occupied terminations when connecting a cable to a circuit 2020-02-11 14:56:02 -05:00
Jeremy Stretch
2503978555 Add navigation menu link for adding config contexts 2020-02-11 14:32:41 -05:00
Anton Tokarev
55886d6793 fix typo (#4135)
Co-authored-by: Jeremy Stretch <jeremy.stretch@networktocode.com>
2020-02-11 14:22:32 -05:00
Jeremy Stretch
009c0ba31c Fixes #4134: Device power ports and outlets should inherit type from the parent device type 2020-02-11 14:18:45 -05:00
Jeremy Stretch
ec53e1c74c Merge pull request #4136 from netbox-community/3912-remove-chained-fields
Closes #3912: Remove chained form fields
2020-02-11 14:01:55 -05:00
Jeremy Stretch
7177fcfa61 Use DynamicModelChoiceField for all fields using APISelect 2020-02-11 12:25:40 -05:00
Jeremy Stretch
fb56d5bc66 Account for initial data when binding a DynamicModelChoiceField 2020-02-11 10:21:44 -05:00
Jeremy Stretch
221805a63e Use TreeNode choice fields for region assignment 2020-02-11 09:50:33 -05:00
Jeremy Stretch
da68968d75 Replace FilterChoiceField with DynamicModelMultipleChoiceField 2020-02-11 09:33:41 -05:00
Dan Sheppard
ca795f729f Merge branch '4034-prefix_ordering_tests' of https://github.com/netbox-community/netbox into 4034-prefix_ordering_tests
 Conflicts:
	netbox/ipam/tests/test_ordering.py
2020-02-10 21:38:53 -06:00
Dan Sheppard
ff4e6bd166 Update tests
Add docstrings
Consolidate tests
2020-02-10 21:37:40 -06:00
Jeremy Stretch
5ea30c8628 Replace ChainedModelChoiceField with DynamicModelChoiceField 2020-02-10 17:23:52 -05:00
Jeremy Stretch
a54fcda781 Merge pull request #4133 from netbox-community/4083-serializer-null-choices
Fixes #4083: Permit nullifying applicable choice fields via API requests
2020-02-10 15:16:38 -05:00
Jeremy Stretch
7388fa3556 Fixes #4083: Permit nullifying applicable choice fields via API requests 2020-02-10 15:10:33 -05:00
Jeremy Stretch
a966a4c8ac Closes #4129: Add individual deletion views for device type components 2020-02-10 11:43:51 -05:00
Jeremy Stretch
ebef48e472 Merge pull request #4075 from kobayashi/3507-filter-by-devices
Fixes #3507: Filtering IP by multiple devices
2020-02-10 11:11:18 -05:00
Jeremy Stretch
26ca6b4a84 #4108: Fix null choice population 2020-02-10 10:57:23 -05:00
Jeremy Stretch
3da6f22479 Merge pull request #4128 from netbox-community/4108-apiselect-performance
Closes #4108: Extraneous queryset evaluation by FilterChoiceFields
2020-02-10 10:26:49 -05:00
Jeremy Stretch
d4789b7c9e Changelog for #4108 2020-02-10 10:20:06 -05:00
Jeremy Stretch
5008526db1 Set a default self.to_field_name for FilterChoiceField 2020-02-10 10:08:20 -05:00
Jeremy Stretch
009fc4f301 Remove custom template for APISelect widget 2020-02-10 10:02:42 -05:00
Jeremy Stretch
55f5ede970 Standardize usage of FilterChoiceField 2020-02-10 09:58:33 -05:00
Jeremy Stretch
5ddfde2214 Clean up unneeded code relevant to FilterChoiceField 2020-02-10 09:44:19 -05: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
kobayashi
505cb9cab8 Enabled filtering virtual chassis devices 2020-02-09 00:58:54 -05:00
kobayashi
d5c4a9d159 Fixes #3507: Filtering IP by multiple devices 2020-02-09 00:42:01 -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
Jeremy Stretch
885ea8a4d5 Override get_bound_field() on FilterChoiceFieldMixin to restrict the queryset of bound fields 2020-02-07 18:04:40 -05:00
Jeremy Stretch
202a0a0e73 Merge pull request #4122 from netbox-community/3799-natural-ordering-field
Closes #3799: Remove NaturalOrderingManager
2020-02-07 16:26:45 -05:00
Jeremy Stretch
5bfd65b5fe Changelog for #3799 2020-02-07 16:18:15 -05:00
Jeremy Stretch
7c74d2ca65 Convert interface models to use NaturalOrderingField 2020-02-07 15:58:03 -05:00
Jeremy Stretch
9adeed55fb Update table field ordering 2020-02-07 12:44:51 -05:00
Jeremy Stretch
12c7d83a91 Fix PowerOutlet migrations 2020-02-07 12:43:53 -05:00
Jeremy Stretch
dc1b7874ff Store empty names as null 2020-02-07 12:24:38 -05:00
Jeremy Stretch
c72a353733 Enable reverse migration 2020-02-07 12:23:52 -05:00
Jeremy Stretch
35511cfdc1 Remove NaturalOrderingManager 2020-02-07 11:59:32 -05:00
Jeremy Stretch
099c446f38 Convert remaining DCIM models to use NaturalOrderingField 2020-02-07 11:59:16 -05:00
Jeremy Stretch
705c352885 Convert device component templates to use NaturalOrderingField 2020-02-07 11:42:12 -05:00
Jeremy Stretch
12d09e2274 Convert device components to use NaturalOrderingField 2020-02-07 11:36:58 -05:00
Jeremy Stretch
b271fd32bd Introduce NaturalOrderingField 2020-02-07 11:36:22 -05:00
Jeremy Stretch
b3c2b78e8a Chnagelog for #4100 2020-02-07 09:24:51 -05:00
Jeremy Stretch
97a89948c8 Merge pull request #4115 from hSaria/4100-interface-filter-device
Fixes #4100: Added device field to device components filter forms
2020-02-07 09:20:49 -05:00
Jeremy Stretch
1e61fcb485 Merge pull request #4117 from netbox-community/4116-component-bulk-actions
Closes #4116: Enable bulk edit and delete functions for device component list views
2020-02-06 22:03:25 -05:00
Jeremy Stretch
4cc9f2f67d Changelog for #4116 2020-02-06 21:52:10 -05:00
Jeremy Stretch
52257467c3 Tweak bulk edit views to dynamically remove device-dependent fields 2020-02-06 21:44:28 -05:00
Jeremy Stretch
4563749fd9 Enable bulk edit/delete views for all device components 2020-02-06 20:58:14 -05:00
Jeremy Stretch
6d242ec348 Correct typo 2020-02-06 20:46:19 -05:00
Saria Hajjar
d0e00162ed Account for header height 2020-02-07 00:14:05 +00:00
Saria Hajjar
21f2e0b131 Changed navbar scrollbar to auto 2020-02-06 23:30:58 +00:00
hSaria
6ac8d41323 Merge branch 'develop' into 3995-navbar-overflow 2020-02-06 23:18:24 +00:00
Saria Hajjar
bb9e1ad857 Fixes #4100: Added device field to device components filter forms 2020-02-06 23:10:38 +00:00
Jeremy Stretch
98de88de90 Standardize URL paths for VM interfaces 2020-02-06 17:40:04 -05:00
Jeremy Stretch
c571aa68be Changelog for #4113 2020-02-06 16:05:30 -05:00
Jeremy Stretch
091d860ae5 Merge pull request #4114 from netbox-community/4113-component-bulk-editing
Closes #4113: Add bulk edit functionality for device type components
2020-02-06 16:03:37 -05:00
Jeremy Stretch
b5344b0aa7 Fix table links for adding new components 2020-02-06 15:54:52 -05:00
Jeremy Stretch
17e0054941 Fix up bulk editing forms 2020-02-06 15:51:51 -05:00
Jeremy Stretch
1b5969a5ee Add tests for DeviceType component bulk edit views 2020-02-06 15:33:47 -05:00
Jeremy Stretch
3378287b0c Add bulk edit views for DeviceType components 2020-02-06 15:29:10 -05:00
Jeremy Stretch
077d692d6d Merge pull request #4112 from netbox-community/4109-devicetype-component-tests
Closes #4109: Add view tests for device component templates
2020-02-06 14:57:10 -05:00
Jeremy Stretch
5620fdc63e Add tests for device type component views 2020-02-06 14:39:36 -05:00
Jeremy Stretch
f7ca97d51f Fix bulk edit buttons 2020-02-06 13:19:25 -05:00
Jeremy Stretch
d400f92ee8 Overhaul device component template creation views/URLs 2020-02-06 13:13:40 -05:00
Jeremy Stretch
c1792653cc Rename device component edit URLs 2020-02-06 12:18:53 -05:00
Jeremy Stretch
aebfb143e0 Rename component template deletion URLs 2020-02-06 12:16:28 -05:00
Jeremy Stretch
ef4ea06f5d Reorganized device component URLs (again) 2020-02-06 11:51:27 -05:00
Jeremy Stretch
85729f3df8 Simplify help_text for ExpandableNameField 2020-02-06 11:44:29 -05:00
Jeremy Stretch
a2475ee501 Remove obsolete ComponentForm 2020-02-06 11:36:25 -05:00
Jeremy Stretch
71601aad39 Replace HiddenInput widget on device component creation forms with APISelect 2020-02-06 11:29:50 -05:00
Jeremy Stretch
c1c8b5e816 Introduce InventoryItemCreateView to bring inventory items up to par with other components 2020-02-06 10:59:13 -05:00
Jeremy Stretch
2296cdc222 Fixes #4090: Render URL custom fields as links under object view 2020-02-05 17:08:39 -05:00
Jeremy Stretch
070b41e694 Reduce number of changelog entries to improve page loading time 2020-02-05 17:01:46 -05:00
Jeremy Stretch
d04626e75f Fixes #4099: Linkify interfaces on global interfaces list 2020-02-05 16:52:42 -05:00
Jeremy Stretch
68738e683a Fixes #4091: Fix filtering of objects by custom fields using UI search form 2020-02-05 16:39:42 -05:00
Jeremy Stretch
3f2c74f5e7 Merge pull request #4096 from elju/patch-1
Updated forms.py to match current naming (Fix for #4095)
2020-02-05 16:25:34 -05:00
Jeremy Stretch
a58bbccfd3 Fixes #4094: Convert unnecessary regular expressions to strings in URL patterns 2020-02-05 16:12:48 -05:00
Jeremy Stretch
b1e78fa3c4 Merge pull request #4098 from netbox-community/4086-device-component-urls
Closes #4086: Rename device component create/edit/delete URLs
2020-02-05 16:03:14 -05:00
Jeremy Stretch
0d3ff664b6 Move model_to_dict() into assertInstanceEqual(); clean up test data 2020-02-05 15:47:50 -05:00
Jeremy Stretch
b0c0ad7c82 Adapt component creation forms to infer parent device/VM from initial or bound data 2020-02-05 15:31:30 -05:00
Jeremy Stretch
0ad613e6b4 Enable bulk creation tests for device components 2020-02-05 15:22:57 -05:00
Jeremy Stretch
75906f7591 Move component bulk creation views to new URLs 2020-02-05 12:36:38 -05:00
El Ju
c49d977379 Updated forms.py to match current naming 2020-02-05 17:26:44 +01:00
Jeremy Stretch
6b9fa5e76f Enable tests for component bulk edit views 2020-02-05 11:14:07 -05:00
Jeremy Stretch
57a0cf0a33 Fix component bulk edit views 2020-02-05 10:28:39 -05:00
Jeremy Stretch
f8ce67c69f Tweak BulkEditView to improve handling of initial PK values 2020-02-05 09:56:49 -05:00
Jeremy Stretch
d0295f089d Fixes #4089: Selection of power outlet type during bulk update is optional 2020-02-04 20:49:42 -05:00
Jeremy Stretch
f805b57778 Adapt BulkEditView to not require a parent object for device components 2020-02-04 18:08:40 -05:00
Jeremy Stretch
3e79b9d26a Add InterfaceTestCase for virtual machines 2020-02-04 16:40:18 -05:00
Jeremy Stretch
c1639b7781 Move component bulk delete views to new URLs 2020-02-04 16:06:55 -05:00
Jeremy Stretch
fca347e49e Reorder URLs 2020-02-04 15:41:15 -05:00
Jeremy Stretch
32623148dc Post-release version bump 2020-02-04 15:06:58 -05:00
Daniel Sheppard
8e9a0eeef0 Fix PEP8 errors and document functions 2020-01-31 10:05:57 -06: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
Dan Sheppard
d30d79b4e3 Cleanup Imports 2020-01-29 12:55:19 -06:00
Dan Sheppard
23155551d1 Remove complex ordering for IP addresses
After review complex ordering does not appear to be required
2020-01-29 12:54:55 -06:00
Dan Sheppard
22228b58f1 #4034 - Create tests for addresses 2020-01-29 12:52:48 -06:00
Dan Sheppard
084a68f6d1 #4034 - Create tests for prefixes 2020-01-28 22:11:31 -06:00
hSaria
deb653cbf3 Merge branch 'develop' into 3840-limit-vlan-choices 2020-01-24 20:54:56 +00:00
hSaria
6ce38ffa0f Merge branch 'develop' into 3995-navbar-overflow 2020-01-23 20:36:35 +00:00
Saria Hajjar
09faaff849 Fixes #3995: Navbar scroll when overflowing 2020-01-23 20:34:06 +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
249 changed files with 7656 additions and 5153 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

3
.github/stale.yml vendored
View File

@@ -1,5 +1,8 @@
# Configuration for Stale (https://github.com/apps/stale)
# Pull requests are exempt from being marked as stale
only: issues
# Number of days of inactivity before an issue becomes stale
daysUntilStale: 14

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.
@@ -177,10 +183,11 @@ An IPv4 or IPv6 network with a mask. Returns a `netaddr.IPNetwork` object. Two a
All variables support the following default options:
* `label` - The name of the form field
* `description` - A brief description of the field
* `default` - The field's default value
* `description` - A brief description of the field
* `label` - The name of the form field
* `required` - Indicates whether the field is mandatory (default: true)
* `widget` - The class of form widget to use (see the [Django documentation](https://docs.djangoproject.com/en/stable/ref/forms/widgets/))
## Example
@@ -195,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 *
@@ -221,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))
@@ -239,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

@@ -109,6 +109,20 @@ In order to send email, NetBox needs an email server configured. The following i
* TIMEOUT - Amount of time to wait for a connection (seconds)
* FROM_EMAIL - Sender address for emails sent by NetBox
Email is sent from NetBox only for critical events. If you would like to test the email server configuration please use the django function [send_mail()](https://docs.djangoproject.com/en/stable/topics/email/#send-mail):
```
# python ./manage.py nbshell
>>> from django.core.mail import send_mail
>>> send_mail(
'Test Email Subject',
'Test Email Body',
'noreply-netbox@example.com',
['users@example.com'],
fail_silently=False
)
```
---
## EXEMPT_VIEW_PERMISSIONS

View File

@@ -21,7 +21,7 @@ NetBox requires access to a PostgreSQL database service to store data. This serv
* `PASSWORD` - PostgreSQL password
* `HOST` - Name or IP address of the database server (use `localhost` if running locally)
* `PORT` - TCP port of the PostgreSQL service; leave blank for default port (5432)
* `CONN_MAX_AGE` - Number in seconds for Netbox to keep database connections open. 150-300 seconds is typically a good starting point ([more info](https://docs.djangoproject.com/en/stable/ref/databases/#persistent-connections)).
* `CONN_MAX_AGE` - Lifetime of a [persistent database connection](https://docs.djangoproject.com/en/stable/ref/databases/#persistent-connections), in seconds (150-300 is recommended)
Example:
@@ -36,6 +36,9 @@ DATABASE = {
}
```
!!! note
NetBox supports all PostgreSQL database options supported by the underlying Django framework. For a complete list of available parameters, please see [the Django documentation](https://docs.djangoproject.com/en/stable/ref/settings/#databases).
---
## REDIS
@@ -77,14 +80,56 @@ 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.
### Using Redis Sentinel
If you are using [Redis Sentinel](https://redis.io/topics/sentinel) for high-availability purposes, there is minimal
configuration necessary to convert NetBox to recognize it. It requires the removal of the `HOST` and `PORT` keys from
above and the addition of two new keys.
* `SENTINELS`: List of tuples or tuple of tuples with each inner tuple containing the name or IP address
of the Redis server and port for each sentinel instance to connect to
* `SENTINEL_SERVICE`: Name of the master / service to connect to
Example:
```python
REDIS = {
'webhooks': {
'SENTINELS': [('mysentinel.redis.example.com', 6379)],
'SENTINEL_SERVICE': 'netbox',
'PASSWORD': '',
'DATABASE': 0,
'DEFAULT_TIMEOUT': 300,
'SSL': False,
},
'caching': {
'SENTINELS': [
('mysentinel.redis.example.com', 6379),
('othersentinel.redis.example.com', 6379)
],
'SENTINEL_SERVICE': 'netbox',
'PASSWORD': '',
'DATABASE': 1,
'DEFAULT_TIMEOUT': 300,
'SSL': False,
}
}
```
!!! 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`.
---
## SECRET_KEY

View File

@@ -32,7 +32,7 @@ pycodestyle --ignore=W504,E501 netbox/
The introduction of a new dependency is best avoided unless it is absolutely necessary. For small features, it's generally preferable to replicate functionality within the NetBox code base rather than to introduce reliance on an external project. This reduces both the burden of tracking new releases and our exposure to outside bugs and attacks.
If there's a strong case for introducing a new depdency, it must meet the following criteria:
If there's a strong case for introducing a new dependency, it must meet the following criteria:
* Its complete source code must be published and freely accessible without registration.
* Its license must be conducive to inclusion in an open source project.
@@ -45,10 +45,18 @@ When adding a new dependency, a short description of the package and the URL of
* When in doubt, remain consistent: It is better to be consistently incorrect than inconsistently correct. If you notice in the course of unrelated work a pattern that should be corrected, continue to follow the pattern for now and open a bug so that the entire code base can be evaluated at a later point.
* Prioritize readability over concision. Python is a very flexible language that typically gives us several options for expressing a given piece of logic, but some may be more friendly to the reader than others. (List comprehensions are particularly vulnerable to over-optimization.) Always remain considerate of the future reader who may need to interpret your code without the benefit of the context within which you are writing it.
* No easter eggs. While they can be fun, NetBox must be considered as a business-critical tool. The potential, however minor, for introducing a bug caused by unnecessary logic is best avoided entirely.
* Constants (variables which generally do not change) should be declared in `constants.py` within each app. Wildcard imports from the file are acceptable.
* Every model should have a docstring. Every custom method should include an expalantion of its function.
* Every model should have a docstring. Every custom method should include an explanation of its function.
* Nested API serializers generate minimal representations of an object. These are stored separately from the primary serializers to avoid circular dependencies. Always import nested serializers from other apps directly. For example, from within the DCIM app you would write `from ipam.api.nested_serializers import NestedIPAddressSerializer`.
## Branding
* When referring to NetBox in writing, use the proper form "NetBox," with the letters N and B capitalized. The lowercase form "netbox" should be used in code, filenames, etc. But never "Netbox" or any other deviation.
* There is an SVG form of the NetBox logo at [docs/netbox_logo.svg](../netbox_logo.svg). It is preferred to use this logo for all purposes as it scales to arbitrary sizes without loss of resolution. If a raster image is required, the SVG logo should be converted to a PNG image of the prescribed size.

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 dameon](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-rqworker
# 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,141 @@
# 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
* [#4166](https://github.com/netbox-community/netbox/issues/4166) - Fix schema migrations to enforce maximum character length for naturalized fields
---
# v2.7.5 (2020-02-13)
**Note:** This release includes several database schema migrations that calculate and store copies of names for certain objects to improve natural ordering performance (see [#3799](https://github.com/netbox-community/netbox/issues/3799)). These migrations may take a few minutes to run if you have a very large number of objects defined in NetBox.
## Enhancements
* [#3766](https://github.com/netbox-community/netbox/issues/3766) - Allow custom script authors to specify the form widget for each variable
* [#3799](https://github.com/netbox-community/netbox/issues/3799) - Greatly improve performance when ordering device components
* [#3984](https://github.com/netbox-community/netbox/issues/3984) - Add support for Redis Sentinel
* [#3986](https://github.com/netbox-community/netbox/issues/3986) - Include position numbers in SVG image when rendering rack elevations
* [#4093](https://github.com/netbox-community/netbox/issues/4093) - Add more status choices for virtual machines
* [#4100](https://github.com/netbox-community/netbox/issues/4100) - Add device filter to component list views
* [#4113](https://github.com/netbox-community/netbox/issues/4113) - Add bulk edit functionality for device type components
* [#4116](https://github.com/netbox-community/netbox/issues/4116) - Enable bulk edit and delete functions for device component list views
* [#4129](https://github.com/netbox-community/netbox/issues/4129) - Add buttons to delete individual device type components
## Bug Fixes
* [#3507](https://github.com/netbox-community/netbox/issues/3507) - Fix filtering IP addresses by multiple devices
* [#3995](https://github.com/netbox-community/netbox/issues/3995) - Make dropdown menus in the navigation bar scrollable on small screens
* [#4083](https://github.com/netbox-community/netbox/issues/4083) - Permit nullifying applicable choice fields via API requests
* [#4089](https://github.com/netbox-community/netbox/issues/4089) - Selection of power outlet type during bulk update is optional
* [#4090](https://github.com/netbox-community/netbox/issues/4090) - Render URL custom fields as links under object view
* [#4091](https://github.com/netbox-community/netbox/issues/4091) - Fix filtering of objects by custom fields using UI search form
* [#4099](https://github.com/netbox-community/netbox/issues/4099) - Linkify interfaces on global interfaces list
* [#4108](https://github.com/netbox-community/netbox/issues/4108) - Avoid extraneous database queries when rendering search forms
* [#4134](https://github.com/netbox-community/netbox/issues/4134) - Device power ports and outlets should inherit type from the parent device type
* [#4137](https://github.com/netbox-community/netbox/issues/4137) - Disable occupied terminations when connecting a cable to a circuit
* [#4138](https://github.com/netbox-community/netbox/issues/4138) - Restore device bay counts in rack elevation diagrams
* [#4146](https://github.com/netbox-community/netbox/issues/4146) - Fix enforcement of secret role assignment for secret decryption
* [#4150](https://github.com/netbox-community/netbox/issues/4150) - Correct YAML rendering of config contexts
* [#4159](https://github.com/netbox-community/netbox/issues/4159) - Fix implementation of Redis caching configuration
---
# v2.7.4 (2020-02-04)
## Enhancements

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'
@@ -41,7 +46,6 @@ pages:
- Prometheus Metrics: 'additional-features/prometheus-metrics.md'
- Reports: 'additional-features/reports.md'
- Tags: 'additional-features/tags.md'
- Topology Maps: 'additional-features/topology-maps.md'
- Webhooks: 'additional-features/webhooks.md'
- Administration:
- Replicating NetBox: 'administration/replicating-netbox.md'
@@ -51,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'
@@ -77,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

@@ -15,15 +15,15 @@ router = routers.DefaultRouter()
router.APIRootView = CircuitsRootView
# Field choices
router.register(r'_choices', views.CircuitsFieldChoicesViewSet, basename='field-choice')
router.register('_choices', views.CircuitsFieldChoicesViewSet, basename='field-choice')
# Providers
router.register(r'providers', views.ProviderViewSet)
router.register('providers', views.ProviderViewSet)
# Circuits
router.register(r'circuit-types', views.CircuitTypeViewSet)
router.register(r'circuits', views.CircuitViewSet)
router.register(r'circuit-terminations', views.CircuitTerminationViewSet)
router.register('circuit-types', views.CircuitTypeViewSet)
router.register('circuits', views.CircuitViewSet)
router.register('circuit-terminations', views.CircuitTerminationViewSet)
app_name = 'circuits-api'
urlpatterns = router.urls

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

@@ -9,7 +9,8 @@ from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant
from utilities.forms import (
APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, CommentField, CSVChoiceField, DatePicker,
FilterChoiceField, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField
DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea, SlugField, StaticSelect2,
StaticSelect2Multiple, TagFilterField,
)
from .choices import CircuitStatusChoices
from .models import Circuit, CircuitTermination, CircuitType, Provider
@@ -107,7 +108,7 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm):
required=False,
label='Search'
)
region = FilterChoiceField(
region = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
required=False,
@@ -119,9 +120,10 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm):
}
)
)
site = FilterChoiceField(
site = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
api_url="/api/dcim/sites/",
value_field="slug",
@@ -164,6 +166,18 @@ class CircuitTypeCSVForm(forms.ModelForm):
#
class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
provider = DynamicModelChoiceField(
queryset=Provider.objects.all(),
widget=APISelect(
api_url="/api/circuits/providers/"
)
)
type = DynamicModelChoiceField(
queryset=CircuitType.objects.all(),
widget=APISelect(
api_url="/api/circuits/circuit-types/"
)
)
comments = CommentField()
tags = TagField(
required=False
@@ -180,12 +194,6 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
'commit_rate': "Committed rate",
}
widgets = {
'provider': APISelect(
api_url="/api/circuits/providers/"
),
'type': APISelect(
api_url="/api/circuits/circuit-types/"
),
'status': StaticSelect2(),
'install_date': DatePicker(),
}
@@ -235,14 +243,14 @@ class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit
queryset=Circuit.objects.all(),
widget=forms.MultipleHiddenInput
)
type = forms.ModelChoiceField(
type = DynamicModelChoiceField(
queryset=CircuitType.objects.all(),
required=False,
widget=APISelect(
api_url="/api/circuits/circuit-types/"
)
)
provider = forms.ModelChoiceField(
provider = DynamicModelChoiceField(
queryset=Provider.objects.all(),
required=False,
widget=APISelect(
@@ -255,7 +263,7 @@ class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit
initial='',
widget=StaticSelect2()
)
tenant = forms.ModelChoiceField(
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
widget=APISelect(
@@ -290,17 +298,19 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
required=False,
label='Search'
)
type = FilterChoiceField(
type = DynamicModelMultipleChoiceField(
queryset=CircuitType.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
api_url="/api/circuits/circuit-types/",
value_field="slug",
)
)
provider = FilterChoiceField(
provider = DynamicModelMultipleChoiceField(
queryset=Provider.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
api_url="/api/circuits/providers/",
value_field="slug",
@@ -311,7 +321,7 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
required=False,
widget=StaticSelect2Multiple()
)
region = forms.ModelMultipleChoiceField(
region = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
required=False,
@@ -323,9 +333,10 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
}
)
)
site = FilterChoiceField(
site = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
api_url="/api/dcim/sites/",
value_field="slug",

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

@@ -2,10 +2,10 @@ import datetime
from circuits.choices import *
from circuits.models import Circuit, CircuitType, Provider
from utilities.testing import StandardTestCases
from utilities.testing import ViewTestCases
class ProviderTestCase(StandardTestCases.Views):
class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = Provider
@classmethod
@@ -46,14 +46,9 @@ class ProviderTestCase(StandardTestCases.Views):
}
class CircuitTypeTestCase(StandardTestCases.Views):
class CircuitTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = CircuitType
# Disable inapplicable tests
test_get_object = None
test_delete_object = None
test_bulk_edit_objects = None
@classmethod
def setUpTestData(cls):
@@ -77,7 +72,7 @@ class CircuitTypeTestCase(StandardTestCases.Views):
)
class CircuitTestCase(StandardTestCases.Views):
class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = Circuit
@classmethod

View File

@@ -9,42 +9,42 @@ app_name = 'circuits'
urlpatterns = [
# Providers
path(r'providers/', views.ProviderListView.as_view(), name='provider_list'),
path(r'providers/add/', views.ProviderCreateView.as_view(), name='provider_add'),
path(r'providers/import/', views.ProviderBulkImportView.as_view(), name='provider_import'),
path(r'providers/edit/', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'),
path(r'providers/delete/', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'),
path(r'providers/<slug:slug>/', views.ProviderView.as_view(), name='provider'),
path(r'providers/<slug:slug>/edit/', views.ProviderEditView.as_view(), name='provider_edit'),
path(r'providers/<slug:slug>/delete/', views.ProviderDeleteView.as_view(), name='provider_delete'),
path(r'providers/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}),
path('providers/', views.ProviderListView.as_view(), name='provider_list'),
path('providers/add/', views.ProviderCreateView.as_view(), name='provider_add'),
path('providers/import/', views.ProviderBulkImportView.as_view(), name='provider_import'),
path('providers/edit/', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'),
path('providers/delete/', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'),
path('providers/<slug:slug>/', views.ProviderView.as_view(), name='provider'),
path('providers/<slug:slug>/edit/', views.ProviderEditView.as_view(), name='provider_edit'),
path('providers/<slug:slug>/delete/', views.ProviderDeleteView.as_view(), name='provider_delete'),
path('providers/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}),
# Circuit types
path(r'circuit-types/', views.CircuitTypeListView.as_view(), name='circuittype_list'),
path(r'circuit-types/add/', views.CircuitTypeCreateView.as_view(), name='circuittype_add'),
path(r'circuit-types/import/', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'),
path(r'circuit-types/delete/', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'),
path(r'circuit-types/<slug:slug>/edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'),
path(r'circuit-types/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='circuittype_changelog', kwargs={'model': CircuitType}),
path('circuit-types/', views.CircuitTypeListView.as_view(), name='circuittype_list'),
path('circuit-types/add/', views.CircuitTypeCreateView.as_view(), name='circuittype_add'),
path('circuit-types/import/', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'),
path('circuit-types/delete/', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'),
path('circuit-types/<slug:slug>/edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'),
path('circuit-types/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='circuittype_changelog', kwargs={'model': CircuitType}),
# Circuits
path(r'circuits/', views.CircuitListView.as_view(), name='circuit_list'),
path(r'circuits/add/', views.CircuitCreateView.as_view(), name='circuit_add'),
path(r'circuits/import/', views.CircuitBulkImportView.as_view(), name='circuit_import'),
path(r'circuits/edit/', views.CircuitBulkEditView.as_view(), name='circuit_bulk_edit'),
path(r'circuits/delete/', views.CircuitBulkDeleteView.as_view(), name='circuit_bulk_delete'),
path(r'circuits/<int:pk>/', views.CircuitView.as_view(), name='circuit'),
path(r'circuits/<int:pk>/edit/', views.CircuitEditView.as_view(), name='circuit_edit'),
path(r'circuits/<int:pk>/delete/', views.CircuitDeleteView.as_view(), name='circuit_delete'),
path(r'circuits/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}),
path(r'circuits/<int:pk>/terminations/swap/', views.circuit_terminations_swap, name='circuit_terminations_swap'),
path('circuits/', views.CircuitListView.as_view(), name='circuit_list'),
path('circuits/add/', views.CircuitCreateView.as_view(), name='circuit_add'),
path('circuits/import/', views.CircuitBulkImportView.as_view(), name='circuit_import'),
path('circuits/edit/', views.CircuitBulkEditView.as_view(), name='circuit_bulk_edit'),
path('circuits/delete/', views.CircuitBulkDeleteView.as_view(), name='circuit_bulk_delete'),
path('circuits/<int:pk>/', views.CircuitView.as_view(), name='circuit'),
path('circuits/<int:pk>/edit/', views.CircuitEditView.as_view(), name='circuit_edit'),
path('circuits/<int:pk>/delete/', views.CircuitDeleteView.as_view(), name='circuit_delete'),
path('circuits/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}),
path('circuits/<int:pk>/terminations/swap/', views.circuit_terminations_swap, name='circuit_terminations_swap'),
# Circuit terminations
path(r'circuits/<int:circuit>/terminations/add/', views.CircuitTerminationCreateView.as_view(), name='circuittermination_add'),
path(r'circuit-terminations/<int:pk>/edit/', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'),
path(r'circuit-terminations/<int:pk>/delete/', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'),
path(r'circuit-terminations/<int:termination_a_id>/connect/<str:termination_b_type>/', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}),
path(r'circuit-terminations/<int:pk>/trace/', CableTraceView.as_view(), name='circuittermination_trace', kwargs={'model': CircuitTermination}),
path('circuits/<int:circuit>/terminations/add/', views.CircuitTerminationCreateView.as_view(), name='circuittermination_add'),
path('circuit-terminations/<int:pk>/edit/', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'),
path('circuit-terminations/<int:pk>/delete/', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'),
path('circuit-terminations/<int:termination_a_id>/connect/<str:termination_b_type>/', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}),
path('circuit-terminations/<int:pk>/trace/', CableTraceView.as_view(), name='circuittermination_trace', kwargs={'model': CircuitTermination}),
]

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

@@ -117,9 +117,9 @@ class RackSerializer(TaggitSerializer, CustomFieldModelSerializer):
tenant = NestedTenantSerializer(required=False, allow_null=True)
status = ChoiceField(choices=RackStatusChoices, required=False)
role = NestedRackRoleSerializer(required=False, allow_null=True)
type = ChoiceField(choices=RackTypeChoices, required=False, allow_null=True)
type = ChoiceField(choices=RackTypeChoices, allow_blank=True, required=False)
width = ChoiceField(choices=RackWidthChoices, required=False)
outer_unit = ChoiceField(choices=RackDimensionUnitChoices, required=False)
outer_unit = ChoiceField(choices=RackDimensionUnitChoices, allow_blank=True, required=False)
tags = TagListSerializerField(required=False)
device_count = serializers.IntegerField(read_only=True)
powerfeed_count = serializers.IntegerField(read_only=True)
@@ -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
)
#
@@ -212,7 +223,7 @@ class ManufacturerSerializer(ValidatedModelSerializer):
class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer):
manufacturer = NestedManufacturerSerializer()
subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, required=False, allow_null=True)
subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False)
tags = TagListSerializerField(required=False)
device_count = serializers.IntegerField(read_only=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',
]
@@ -228,6 +240,7 @@ class ConsolePortTemplateSerializer(ValidatedModelSerializer):
device_type = NestedDeviceTypeSerializer()
type = ChoiceField(
choices=ConsolePortTypeChoices,
allow_blank=True,
required=False
)
@@ -240,6 +253,7 @@ class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
device_type = NestedDeviceTypeSerializer()
type = ChoiceField(
choices=ConsolePortTypeChoices,
allow_blank=True,
required=False
)
@@ -252,6 +266,7 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer):
device_type = NestedDeviceTypeSerializer()
type = ChoiceField(
choices=PowerPortTypeChoices,
allow_blank=True,
required=False
)
@@ -264,15 +279,16 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
device_type = NestedDeviceTypeSerializer()
type = ChoiceField(
choices=PowerOutletTypeChoices,
allow_blank=True,
required=False
)
power_port = PowerPortTemplateSerializer(
power_port = NestedPowerPortTemplateSerializer(
required=False
)
feed_leg = ChoiceField(
choices=PowerOutletFeedLegChoices,
required=False,
allow_null=True
allow_blank=True,
required=False
)
class Meta:
@@ -282,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
@@ -351,7 +367,7 @@ class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer):
platform = NestedPlatformSerializer(required=False, allow_null=True)
site = NestedSiteSerializer()
rack = NestedRackSerializer(required=False, allow_null=True)
face = ChoiceField(choices=DeviceFaceChoices, required=False, allow_null=True)
face = ChoiceField(choices=DeviceFaceChoices, allow_blank=True, required=False)
status = ChoiceField(choices=DeviceStatusChoices, required=False)
primary_ip = NestedIPAddressSerializer(read_only=True)
primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
@@ -420,6 +436,7 @@ class ConsoleServerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer)
device = NestedDeviceSerializer()
type = ChoiceField(
choices=ConsolePortTypeChoices,
allow_blank=True,
required=False
)
cable = NestedCableSerializer(read_only=True)
@@ -437,6 +454,7 @@ class ConsolePortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
device = NestedDeviceSerializer()
type = ChoiceField(
choices=ConsolePortTypeChoices,
allow_blank=True,
required=False
)
cable = NestedCableSerializer(read_only=True)
@@ -454,6 +472,7 @@ class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer):
device = NestedDeviceSerializer()
type = ChoiceField(
choices=PowerOutletTypeChoices,
allow_blank=True,
required=False
)
power_port = NestedPowerPortSerializer(
@@ -461,8 +480,8 @@ class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer):
)
feed_leg = ChoiceField(
choices=PowerOutletFeedLegChoices,
required=False,
allow_null=True
allow_blank=True,
required=False
)
cable = NestedCableSerializer(
read_only=True
@@ -483,6 +502,7 @@ class PowerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
device = NestedDeviceSerializer()
type = ChoiceField(
choices=PowerPortTypeChoices,
allow_blank=True,
required=False
)
cable = NestedCableSerializer(read_only=True)
@@ -498,9 +518,9 @@ 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, required=False, allow_null=True)
mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False)
untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
tagged_vlans = SerializedPKRelatedField(
queryset=VLAN.objects.all(),
@@ -617,7 +637,7 @@ class CableSerializer(ValidatedModelSerializer):
termination_a = serializers.SerializerMethodField(read_only=True)
termination_b = serializers.SerializerMethodField(read_only=True)
status = ChoiceField(choices=CableStatusChoices, required=False)
length_unit = ChoiceField(choices=CableLengthUnitChoices, required=False, allow_null=True)
length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False)
class Meta:
model = Cable

View File

@@ -15,65 +15,65 @@ router = routers.DefaultRouter()
router.APIRootView = DCIMRootView
# Field choices
router.register(r'_choices', views.DCIMFieldChoicesViewSet, basename='field-choice')
router.register('_choices', views.DCIMFieldChoicesViewSet, basename='field-choice')
# Sites
router.register(r'regions', views.RegionViewSet)
router.register(r'sites', views.SiteViewSet)
router.register('regions', views.RegionViewSet)
router.register('sites', views.SiteViewSet)
# Racks
router.register(r'rack-groups', views.RackGroupViewSet)
router.register(r'rack-roles', views.RackRoleViewSet)
router.register(r'racks', views.RackViewSet)
router.register(r'rack-reservations', views.RackReservationViewSet)
router.register('rack-groups', views.RackGroupViewSet)
router.register('rack-roles', views.RackRoleViewSet)
router.register('racks', views.RackViewSet)
router.register('rack-reservations', views.RackReservationViewSet)
# Device types
router.register(r'manufacturers', views.ManufacturerViewSet)
router.register(r'device-types', views.DeviceTypeViewSet)
router.register('manufacturers', views.ManufacturerViewSet)
router.register('device-types', views.DeviceTypeViewSet)
# Device type components
router.register(r'console-port-templates', views.ConsolePortTemplateViewSet)
router.register(r'console-server-port-templates', views.ConsoleServerPortTemplateViewSet)
router.register(r'power-port-templates', views.PowerPortTemplateViewSet)
router.register(r'power-outlet-templates', views.PowerOutletTemplateViewSet)
router.register(r'interface-templates', views.InterfaceTemplateViewSet)
router.register(r'front-port-templates', views.FrontPortTemplateViewSet)
router.register(r'rear-port-templates', views.RearPortTemplateViewSet)
router.register(r'device-bay-templates', views.DeviceBayTemplateViewSet)
router.register('console-port-templates', views.ConsolePortTemplateViewSet)
router.register('console-server-port-templates', views.ConsoleServerPortTemplateViewSet)
router.register('power-port-templates', views.PowerPortTemplateViewSet)
router.register('power-outlet-templates', views.PowerOutletTemplateViewSet)
router.register('interface-templates', views.InterfaceTemplateViewSet)
router.register('front-port-templates', views.FrontPortTemplateViewSet)
router.register('rear-port-templates', views.RearPortTemplateViewSet)
router.register('device-bay-templates', views.DeviceBayTemplateViewSet)
# Devices
router.register(r'device-roles', views.DeviceRoleViewSet)
router.register(r'platforms', views.PlatformViewSet)
router.register(r'devices', views.DeviceViewSet)
router.register('device-roles', views.DeviceRoleViewSet)
router.register('platforms', views.PlatformViewSet)
router.register('devices', views.DeviceViewSet)
# Device components
router.register(r'console-ports', views.ConsolePortViewSet)
router.register(r'console-server-ports', views.ConsoleServerPortViewSet)
router.register(r'power-ports', views.PowerPortViewSet)
router.register(r'power-outlets', views.PowerOutletViewSet)
router.register(r'interfaces', views.InterfaceViewSet)
router.register(r'front-ports', views.FrontPortViewSet)
router.register(r'rear-ports', views.RearPortViewSet)
router.register(r'device-bays', views.DeviceBayViewSet)
router.register(r'inventory-items', views.InventoryItemViewSet)
router.register('console-ports', views.ConsolePortViewSet)
router.register('console-server-ports', views.ConsoleServerPortViewSet)
router.register('power-ports', views.PowerPortViewSet)
router.register('power-outlets', views.PowerOutletViewSet)
router.register('interfaces', views.InterfaceViewSet)
router.register('front-ports', views.FrontPortViewSet)
router.register('rear-ports', views.RearPortViewSet)
router.register('device-bays', views.DeviceBayViewSet)
router.register('inventory-items', views.InventoryItemViewSet)
# Connections
router.register(r'console-connections', views.ConsoleConnectionViewSet, basename='consoleconnections')
router.register(r'power-connections', views.PowerConnectionViewSet, basename='powerconnections')
router.register(r'interface-connections', views.InterfaceConnectionViewSet, basename='interfaceconnections')
router.register('console-connections', views.ConsoleConnectionViewSet, basename='consoleconnections')
router.register('power-connections', views.PowerConnectionViewSet, basename='powerconnections')
router.register('interface-connections', views.InterfaceConnectionViewSet, basename='interfaceconnections')
# Cables
router.register(r'cables', views.CableViewSet)
router.register('cables', views.CableViewSet)
# Virtual chassis
router.register(r'virtual-chassis', views.VirtualChassisViewSet)
router.register('virtual-chassis', views.VirtualChassisViewSet)
# Power
router.register(r'power-panels', views.PowerPanelViewSet)
router.register(r'power-feeds', views.PowerFeedViewSet)
router.register('power-panels', views.PowerPanelViewSet)
router.register('power-feeds', views.PowerFeedViewSet)
# Miscellaneous
router.register(r'connected-device', views.ConnectedDeviceViewSet, basename='connected-device')
router.register('connected-device', views.ConnectedDeviceViewSet, basename='connected-device')
app_name = 'dcim-api'
urlpatterns = router.urls

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,8 +9,10 @@ from .choices import InterfaceTypeChoices
RACK_U_HEIGHT_DEFAULT = 42
RACK_ELEVATION_UNIT_WIDTH_DEFAULT = 230
RACK_ELEVATION_UNIT_HEIGHT_DEFAULT = 20
RACK_ELEVATION_BORDER_WIDTH = 2
RACK_ELEVATION_LEGEND_WIDTH_DEFAULT = 30
RACK_ELEVATION_UNIT_WIDTH_DEFAULT = 220
RACK_ELEVATION_UNIT_HEIGHT_DEFAULT = 22
#
@@ -59,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)',
)

File diff suppressed because it is too large Load Diff

View File

@@ -1,73 +0,0 @@
from django.db.models import Manager, QuerySet
from django.db.models.expressions import RawSQL
from .constants import NONCONNECTABLE_IFACE_TYPES
# Regular expressions for parsing Interface names
TYPE_RE = r"SUBSTRING({} FROM '^([^0-9\.:]+)')"
SLOT_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(\d{{1,9}})/') AS integer), NULL)"
SUBSLOT_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9\.:]+)?\d{{1,9}}/(\d{{1,9}})') AS integer), NULL)"
POSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:\d{{1,9}}/){{2}}(\d{{1,9}})') AS integer), NULL)"
SUBPOSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:\d{{1,9}}/){{3}}(\d{{1,9}})') AS integer), NULL)"
ID_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9\.:]+)?(\d{{1,9}})([^/]|$)') AS integer)"
CHANNEL_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^.*:(\d{{1,9}})(\.\d{{1,9}})?$') AS integer), 0)"
VC_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^.*\.(\d{{1,9}})$') AS integer), 0)"
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):
"""
Naturally order interfaces by their type and numeric position. To order interfaces naturally, the `name` field
is split into eight distinct components: leading text (type), slot, subslot, position, subposition, ID, channel,
and virtual circuit:
{type}{slot or ID}/{subslot}/{position}/{subposition}:{channel}.{vc}
Components absent from the interface name are coalesced to zero or null. For example, an interface named
GigabitEthernet1/2/3 would be parsed as follows:
type = 'GigabitEthernet'
slot = 1
subslot = 2
position = 3
subposition = None
id = None
channel = 0
vc = 0
The original `name` field is considered in its entirety to serve as a fallback in the event interfaces do not
match any of the prescribed fields.
The `id` field is included to enforce deterministic ordering of interfaces in similar vein of other device
components.
"""
sql_col = '{}.name'.format(self.model._meta.db_table)
ordering = [
'_slot', '_subslot', '_position', '_subposition', '_type', '_id', '_channel', '_vc', 'name', 'pk'
]
fields = {
'_type': RawSQL(TYPE_RE.format(sql_col), []),
'_id': RawSQL(ID_RE.format(sql_col), []),
'_slot': RawSQL(SLOT_RE.format(sql_col), []),
'_subslot': RawSQL(SUBSLOT_RE.format(sql_col), []),
'_position': RawSQL(POSITION_RE.format(sql_col), []),
'_subposition': RawSQL(SUBPOSITION_RE.format(sql_col), []),
'_channel': RawSQL(CHANNEL_RE.format(sql_col), []),
'_vc': RawSQL(VC_RE.format(sql_col), []),
}
return InterfaceQuerySet(self.model, using=self._db).annotate(**fields).order_by(*ordering)

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,147 @@
from django.db import migrations
import utilities.fields
import utilities.ordering
def _update_model_names(model):
# Update each unique field value in bulk
for name in model.objects.values_list('name', flat=True).order_by('name').distinct():
model.objects.filter(name=name).update(_name=utilities.ordering.naturalize(name, max_length=100))
def naturalize_consoleports(apps, schema_editor):
_update_model_names(apps.get_model('dcim', 'ConsolePort'))
def naturalize_consoleserverports(apps, schema_editor):
_update_model_names(apps.get_model('dcim', 'ConsoleServerPort'))
def naturalize_powerports(apps, schema_editor):
_update_model_names(apps.get_model('dcim', 'PowerPort'))
def naturalize_poweroutlets(apps, schema_editor):
_update_model_names(apps.get_model('dcim', 'PowerOutlet'))
def naturalize_frontports(apps, schema_editor):
_update_model_names(apps.get_model('dcim', 'FrontPort'))
def naturalize_rearports(apps, schema_editor):
_update_model_names(apps.get_model('dcim', 'RearPort'))
def naturalize_devicebays(apps, schema_editor):
_update_model_names(apps.get_model('dcim', 'DeviceBay'))
class Migration(migrations.Migration):
dependencies = [
('dcim', '0092_fix_rack_outer_unit'),
]
operations = [
migrations.AlterModelOptions(
name='consoleport',
options={'ordering': ('device', '_name')},
),
migrations.AlterModelOptions(
name='consoleserverport',
options={'ordering': ('device', '_name')},
),
migrations.AlterModelOptions(
name='devicebay',
options={'ordering': ('device', '_name')},
),
migrations.AlterModelOptions(
name='frontport',
options={'ordering': ('device', '_name')},
),
migrations.AlterModelOptions(
name='inventoryitem',
options={'ordering': ('device__id', 'parent__id', '_name')},
),
migrations.AlterModelOptions(
name='poweroutlet',
options={'ordering': ('device', '_name')},
),
migrations.AlterModelOptions(
name='powerport',
options={'ordering': ('device', '_name')},
),
migrations.AlterModelOptions(
name='rearport',
options={'ordering': ('device', '_name')},
),
migrations.AddField(
model_name='consoleport',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='consoleserverport',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='devicebay',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='frontport',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='inventoryitem',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='poweroutlet',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='powerport',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='rearport',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.RunPython(
code=naturalize_consoleports,
reverse_code=migrations.RunPython.noop
),
migrations.RunPython(
code=naturalize_consoleserverports,
reverse_code=migrations.RunPython.noop
),
migrations.RunPython(
code=naturalize_powerports,
reverse_code=migrations.RunPython.noop
),
migrations.RunPython(
code=naturalize_poweroutlets,
reverse_code=migrations.RunPython.noop
),
migrations.RunPython(
code=naturalize_frontports,
reverse_code=migrations.RunPython.noop
),
migrations.RunPython(
code=naturalize_rearports,
reverse_code=migrations.RunPython.noop
),
migrations.RunPython(
code=naturalize_devicebays,
reverse_code=migrations.RunPython.noop
),
]

View File

@@ -0,0 +1,138 @@
from django.db import migrations
import utilities.fields
import utilities.ordering
def _update_model_names(model):
# Update each unique field value in bulk
for name in model.objects.values_list('name', flat=True).order_by('name').distinct():
model.objects.filter(name=name).update(_name=utilities.ordering.naturalize(name, max_length=100))
def naturalize_consoleporttemplates(apps, schema_editor):
_update_model_names(apps.get_model('dcim', 'ConsolePortTemplate'))
def naturalize_consoleserverporttemplates(apps, schema_editor):
_update_model_names(apps.get_model('dcim', 'ConsoleServerPortTemplate'))
def naturalize_powerporttemplates(apps, schema_editor):
_update_model_names(apps.get_model('dcim', 'PowerPortTemplate'))
def naturalize_poweroutlettemplates(apps, schema_editor):
_update_model_names(apps.get_model('dcim', 'PowerOutletTemplate'))
def naturalize_frontporttemplates(apps, schema_editor):
_update_model_names(apps.get_model('dcim', 'FrontPortTemplate'))
def naturalize_rearporttemplates(apps, schema_editor):
_update_model_names(apps.get_model('dcim', 'RearPortTemplate'))
def naturalize_devicebaytemplates(apps, schema_editor):
_update_model_names(apps.get_model('dcim', 'DeviceBayTemplate'))
class Migration(migrations.Migration):
dependencies = [
('dcim', '0093_device_component_ordering'),
]
operations = [
migrations.AlterModelOptions(
name='consoleporttemplate',
options={'ordering': ('device_type', '_name')},
),
migrations.AlterModelOptions(
name='consoleserverporttemplate',
options={'ordering': ('device_type', '_name')},
),
migrations.AlterModelOptions(
name='devicebaytemplate',
options={'ordering': ('device_type', '_name')},
),
migrations.AlterModelOptions(
name='frontporttemplate',
options={'ordering': ('device_type', '_name')},
),
migrations.AlterModelOptions(
name='poweroutlettemplate',
options={'ordering': ('device_type', '_name')},
),
migrations.AlterModelOptions(
name='powerporttemplate',
options={'ordering': ('device_type', '_name')},
),
migrations.AlterModelOptions(
name='rearporttemplate',
options={'ordering': ('device_type', '_name')},
),
migrations.AddField(
model_name='consoleporttemplate',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='consoleserverporttemplate',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='devicebaytemplate',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='frontporttemplate',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='poweroutlettemplate',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='powerporttemplate',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='rearporttemplate',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.RunPython(
code=naturalize_consoleporttemplates,
reverse_code=migrations.RunPython.noop
),
migrations.RunPython(
code=naturalize_consoleserverporttemplates,
reverse_code=migrations.RunPython.noop
),
migrations.RunPython(
code=naturalize_powerporttemplates,
reverse_code=migrations.RunPython.noop
),
migrations.RunPython(
code=naturalize_poweroutlettemplates,
reverse_code=migrations.RunPython.noop
),
migrations.RunPython(
code=naturalize_frontporttemplates,
reverse_code=migrations.RunPython.noop
),
migrations.RunPython(
code=naturalize_rearporttemplates,
reverse_code=migrations.RunPython.noop
),
migrations.RunPython(
code=naturalize_devicebaytemplates,
reverse_code=migrations.RunPython.noop
),
]

View File

@@ -0,0 +1,70 @@
from django.db import migrations
import utilities.fields
import utilities.ordering
def _update_model_names(model):
# Update each unique field value in bulk
for name in model.objects.values_list('name', flat=True).order_by('name').distinct():
model.objects.filter(name=name).update(_name=utilities.ordering.naturalize(name, max_length=100))
def naturalize_sites(apps, schema_editor):
_update_model_names(apps.get_model('dcim', 'Site'))
def naturalize_racks(apps, schema_editor):
_update_model_names(apps.get_model('dcim', 'Rack'))
def naturalize_devices(apps, schema_editor):
_update_model_names(apps.get_model('dcim', 'Device'))
class Migration(migrations.Migration):
dependencies = [
('dcim', '0094_device_component_template_ordering'),
]
operations = [
migrations.AlterModelOptions(
name='device',
options={'ordering': ('_name', 'pk'), 'permissions': (('napalm_read', 'Read-only access to devices via NAPALM'), ('napalm_write', 'Read/write access to devices via NAPALM'))},
),
migrations.AlterModelOptions(
name='rack',
options={'ordering': ('site', 'group', '_name', 'pk')},
),
migrations.AlterModelOptions(
name='site',
options={'ordering': ('_name',)},
),
migrations.AddField(
model_name='device',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize, null=True),
),
migrations.AddField(
model_name='rack',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='site',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.RunPython(
code=naturalize_sites,
reverse_code=migrations.RunPython.noop
),
migrations.RunPython(
code=naturalize_racks,
reverse_code=migrations.RunPython.noop
),
migrations.RunPython(
code=naturalize_devices,
reverse_code=migrations.RunPython.noop
),
]

View File

@@ -0,0 +1,53 @@
from django.db import migrations
import utilities.fields
import utilities.ordering
def _update_model_names(model):
# Update each unique field value in bulk
for name in model.objects.values_list('name', flat=True).order_by('name').distinct():
model.objects.filter(name=name).update(_name=utilities.ordering.naturalize_interface(name, max_length=100))
def naturalize_interfacetemplates(apps, schema_editor):
_update_model_names(apps.get_model('dcim', 'InterfaceTemplate'))
def naturalize_interfaces(apps, schema_editor):
_update_model_names(apps.get_model('dcim', 'Interface'))
class Migration(migrations.Migration):
dependencies = [
('dcim', '0095_primary_model_ordering'),
]
operations = [
migrations.AlterModelOptions(
name='interface',
options={'ordering': ('device', '_name')},
),
migrations.AlterModelOptions(
name='interfacetemplate',
options={'ordering': ('device_type', '_name')},
),
migrations.AddField(
model_name='interface',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface),
),
migrations.AddField(
model_name='interfacetemplate',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface),
),
migrations.RunPython(
code=naturalize_interfacetemplates,
reverse_code=migrations.RunPython.noop
),
migrations.RunPython(
code=naturalize_interfaces,
reverse_code=migrations.RunPython.noop
),
]

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,11 +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 utilities.fields import ColorField
from utilities.managers import NaturalOrderingManager
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,
@@ -120,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
@@ -134,6 +142,11 @@ class Site(ChangeLoggedModel, CustomFieldModel):
max_length=50,
unique=True
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
slug = models.SlugField(
unique=True
)
@@ -215,8 +228,6 @@ class Site(ChangeLoggedModel, CustomFieldModel):
images = GenericRelation(
to='extras.ImageAttachment'
)
objects = NaturalOrderingManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = [
@@ -235,7 +246,7 @@ class Site(ChangeLoggedModel, CustomFieldModel):
}
class Meta:
ordering = ['name']
ordering = ('_name',)
def __str__(self):
return self.name
@@ -348,167 +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):
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(device), 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):
drawing = self._setup_drawing(unit_width, unit_height * self.u_height)
unit_cursor = 0
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 = (0, start_y)
end_cordinates = (unit_width, end_y)
text_cordinates = (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((0, 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
):
"""
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)
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.
@@ -516,6 +367,11 @@ class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin):
name = models.CharField(
max_length=50
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
facility_id = models.CharField(
max_length=50,
blank=True,
@@ -612,8 +468,6 @@ class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin):
images = GenericRelation(
to='extras.ImageAttachment'
)
objects = NaturalOrderingManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = [
@@ -634,12 +488,12 @@ class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin):
}
class Meta:
ordering = ('site', 'group', 'name', 'pk') # (site, group, name) may be non-unique
unique_together = [
ordering = ('site', 'group', '_name', 'pk') # (site, group, name) may be non-unique
unique_together = (
# Name and facility_id must be unique *only* within a RackGroup
['group', 'name'],
['group', 'facility_id'],
]
('group', 'name'),
('group', 'facility_id'),
)
def __str__(self):
return self.display_name or super().__str__()
@@ -817,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)
@@ -1007,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
)
@@ -1038,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])
@@ -1157,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)
@@ -1313,6 +1221,12 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
blank=True,
null=True
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True,
null=True
)
serial = models.CharField(
max_length=50,
blank=True,
@@ -1407,8 +1321,6 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
images = GenericRelation(
to='extras.ImageAttachment'
)
objects = NaturalOrderingManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = [
@@ -1430,12 +1342,12 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
}
class Meta:
ordering = ('name', 'pk') # Name may be NULL
unique_together = [
['site', 'tenant', 'name'], # See validate_unique below
['rack', 'position', 'face'],
['virtual_chassis', 'vc_position'],
]
ordering = ('_name', 'pk') # Name may be null
unique_together = (
('site', 'tenant', 'name'), # See validate_unique below
('rack', 'position', 'face'),
('virtual_chassis', 'vc_position'),
)
permissions = (
('napalm_read', 'Read-only access to devices via NAPALM'),
('napalm_write', 'Read/write access to devices via NAPALM'),
@@ -1864,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)],
@@ -1948,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:
@@ -2054,6 +1972,7 @@ class Cable(ChangeLoggedModel):
STATUS_CLASS_MAP = {
CableStatusChoices.STATUS_CONNECTED: 'success',
CableStatusChoices.STATUS_PLANNED: 'info',
CableStatusChoices.STATUS_DECOMMISSIONING: 'warning',
}
class Meta:
@@ -2214,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,12 +1,12 @@
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
from dcim.choices import *
from dcim.constants import *
from dcim.managers import InterfaceManager
from extras.models import ObjectChange
from utilities.managers import NaturalOrderingManager
from utilities.fields import NaturalOrderingField
from utilities.ordering import naturalize_interface
from utilities.utils import serialize_object
from .device_components import (
ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, PowerOutlet, PowerPort, RearPort,
@@ -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)
)
@@ -58,17 +64,20 @@ class ConsolePortTemplate(ComponentTemplateModel):
name = models.CharField(
max_length=50
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
type = models.CharField(
max_length=50,
choices=ConsolePortTypeChoices,
blank=True
)
objects = NaturalOrderingManager()
class Meta:
ordering = ['device_type', 'name']
unique_together = ['device_type', 'name']
ordering = ('device_type', '_name')
unique_together = ('device_type', 'name')
def __str__(self):
return self.name
@@ -93,17 +102,20 @@ class ConsoleServerPortTemplate(ComponentTemplateModel):
name = models.CharField(
max_length=50
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
type = models.CharField(
max_length=50,
choices=ConsolePortTypeChoices,
blank=True
)
objects = NaturalOrderingManager()
class Meta:
ordering = ['device_type', 'name']
unique_together = ['device_type', 'name']
ordering = ('device_type', '_name')
unique_together = ('device_type', 'name')
def __str__(self):
return self.name
@@ -128,6 +140,11 @@ class PowerPortTemplate(ComponentTemplateModel):
name = models.CharField(
max_length=50
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
type = models.CharField(
max_length=50,
choices=PowerPortTypeChoices,
@@ -146,11 +163,9 @@ class PowerPortTemplate(ComponentTemplateModel):
help_text="Allocated power draw (watts)"
)
objects = NaturalOrderingManager()
class Meta:
ordering = ['device_type', 'name']
unique_together = ['device_type', 'name']
ordering = ('device_type', '_name')
unique_together = ('device_type', 'name')
def __str__(self):
return self.name
@@ -159,6 +174,7 @@ class PowerPortTemplate(ComponentTemplateModel):
return PowerPort(
device=device,
name=self.name,
type=self.type,
maximum_draw=self.maximum_draw,
allocated_draw=self.allocated_draw
)
@@ -176,6 +192,11 @@ class PowerOutletTemplate(ComponentTemplateModel):
name = models.CharField(
max_length=50
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
type = models.CharField(
max_length=50,
choices=PowerOutletTypeChoices,
@@ -195,11 +216,9 @@ class PowerOutletTemplate(ComponentTemplateModel):
help_text="Phase (for three-phase feeds)"
)
objects = NaturalOrderingManager()
class Meta:
ordering = ['device_type', 'name']
unique_together = ['device_type', 'name']
ordering = ('device_type', '_name')
unique_together = ('device_type', 'name')
def __str__(self):
return self.name
@@ -220,6 +239,7 @@ class PowerOutletTemplate(ComponentTemplateModel):
return PowerOutlet(
device=device,
name=self.name,
type=self.type,
power_port=power_port,
feed_leg=self.feed_leg
)
@@ -237,6 +257,12 @@ class InterfaceTemplate(ComponentTemplateModel):
name = models.CharField(
max_length=64
)
_name = NaturalOrderingField(
target_field='name',
naturalize_function=naturalize_interface,
max_length=100,
blank=True
)
type = models.CharField(
max_length=50,
choices=InterfaceTypeChoices
@@ -246,11 +272,9 @@ class InterfaceTemplate(ComponentTemplateModel):
verbose_name='Management only'
)
objects = InterfaceManager()
class Meta:
ordering = ['device_type', 'name']
unique_together = ['device_type', 'name']
ordering = ('device_type', '_name')
unique_together = ('device_type', 'name')
def __str__(self):
return self.name
@@ -276,6 +300,11 @@ class FrontPortTemplate(ComponentTemplateModel):
name = models.CharField(
max_length=64
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
type = models.CharField(
max_length=50,
choices=PortTypeChoices
@@ -290,14 +319,12 @@ class FrontPortTemplate(ComponentTemplateModel):
validators=[MinValueValidator(1), MaxValueValidator(64)]
)
objects = NaturalOrderingManager()
class Meta:
ordering = ['device_type', 'name']
unique_together = [
['device_type', 'name'],
['rear_port', 'rear_port_position'],
]
ordering = ('device_type', '_name')
unique_together = (
('device_type', 'name'),
('rear_port', 'rear_port_position'),
)
def __str__(self):
return self.name
@@ -344,6 +371,11 @@ class RearPortTemplate(ComponentTemplateModel):
name = models.CharField(
max_length=64
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
type = models.CharField(
max_length=50,
choices=PortTypeChoices
@@ -353,11 +385,9 @@ class RearPortTemplate(ComponentTemplateModel):
validators=[MinValueValidator(1), MaxValueValidator(64)]
)
objects = NaturalOrderingManager()
class Meta:
ordering = ['device_type', 'name']
unique_together = ['device_type', 'name']
ordering = ('device_type', '_name')
unique_together = ('device_type', 'name')
def __str__(self):
return self.name
@@ -383,12 +413,15 @@ class DeviceBayTemplate(ComponentTemplateModel):
name = models.CharField(
max_length=50
)
objects = NaturalOrderingManager()
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
class Meta:
ordering = ['device_type', 'name']
unique_together = ['device_type', 'name']
ordering = ('device_type', '_name')
unique_together = ('device_type', 'name')
def __str__(self):
return self.name

View File

@@ -10,9 +10,9 @@ from dcim.choices import *
from dcim.constants import *
from dcim.exceptions import LoopDetected
from dcim.fields import MACAddressField
from dcim.managers import InterfaceManager
from extras.models import ObjectChange, TaggedItem
from utilities.managers import NaturalOrderingManager
from utilities.fields import NaturalOrderingField
from utilities.ordering import naturalize_interface
from utilities.utils import serialize_object
from virtualization.choices import VMInterfaceTypeChoices
@@ -181,6 +181,11 @@ class ConsolePort(CableTermination, ComponentModel):
name = models.CharField(
max_length=50
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
type = models.CharField(
max_length=50,
choices=ConsolePortTypeChoices,
@@ -197,15 +202,13 @@ class ConsolePort(CableTermination, ComponentModel):
choices=CONNECTION_STATUS_CHOICES,
blank=True
)
objects = NaturalOrderingManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = ['device', 'name', 'type', 'description']
class Meta:
ordering = ['device', 'name']
unique_together = ['device', 'name']
ordering = ('device', '_name')
unique_together = ('device', 'name')
def __str__(self):
return self.name
@@ -238,6 +241,11 @@ class ConsoleServerPort(CableTermination, ComponentModel):
name = models.CharField(
max_length=50
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
type = models.CharField(
max_length=50,
choices=ConsolePortTypeChoices,
@@ -247,14 +255,13 @@ class ConsoleServerPort(CableTermination, ComponentModel):
choices=CONNECTION_STATUS_CHOICES,
blank=True
)
objects = NaturalOrderingManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = ['device', 'name', 'type', 'description']
class Meta:
unique_together = ['device', 'name']
ordering = ('device', '_name')
unique_together = ('device', 'name')
def __str__(self):
return self.name
@@ -287,6 +294,11 @@ class PowerPort(CableTermination, ComponentModel):
name = models.CharField(
max_length=50
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
type = models.CharField(
max_length=50,
choices=PowerPortTypeChoices,
@@ -322,15 +334,13 @@ class PowerPort(CableTermination, ComponentModel):
choices=CONNECTION_STATUS_CHOICES,
blank=True
)
objects = NaturalOrderingManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = ['device', 'name', 'type', 'maximum_draw', 'allocated_draw', 'description']
class Meta:
ordering = ['device', 'name']
unique_together = ['device', 'name']
ordering = ('device', '_name')
unique_together = ('device', 'name')
def __str__(self):
return self.name
@@ -350,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):
@@ -433,6 +455,11 @@ class PowerOutlet(CableTermination, ComponentModel):
name = models.CharField(
max_length=50
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
type = models.CharField(
max_length=50,
choices=PowerOutletTypeChoices,
@@ -455,14 +482,13 @@ class PowerOutlet(CableTermination, ComponentModel):
choices=CONNECTION_STATUS_CHOICES,
blank=True
)
objects = NaturalOrderingManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = ['device', 'name', 'type', 'power_port', 'feed_leg', 'description']
class Meta:
unique_together = ['device', 'name']
ordering = ('device', '_name')
unique_together = ('device', 'name')
def __str__(self):
return self.name
@@ -515,6 +541,12 @@ class Interface(CableTermination, ComponentModel):
name = models.CharField(
max_length=64
)
_name = NaturalOrderingField(
target_field='name',
naturalize_function=naturalize_interface,
max_length=100,
blank=True
)
_connected_interface = models.OneToOneField(
to='self',
on_delete=models.SET_NULL,
@@ -583,8 +615,6 @@ class Interface(CableTermination, ComponentModel):
blank=True,
verbose_name='Tagged VLANs'
)
objects = InterfaceManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = [
@@ -593,8 +623,9 @@ class Interface(CableTermination, ComponentModel):
]
class Meta:
ordering = ['device', 'name']
unique_together = ['device', 'name']
# TODO: ordering and unique_together should include virtual_machine
ordering = ('device', '_name')
unique_together = ('device', 'name')
def __str__(self):
return self.name
@@ -698,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):
@@ -761,6 +804,11 @@ class FrontPort(CableTermination, ComponentModel):
name = models.CharField(
max_length=64
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
type = models.CharField(
max_length=50,
choices=PortTypeChoices
@@ -774,20 +822,17 @@ class FrontPort(CableTermination, ComponentModel):
default=1,
validators=[MinValueValidator(1), MaxValueValidator(64)]
)
is_path_endpoint = False
objects = NaturalOrderingManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = ['device', 'name', 'type', 'rear_port', 'rear_port_position', 'description']
is_path_endpoint = False
class Meta:
ordering = ['device', 'name']
unique_together = [
['device', 'name'],
['rear_port', 'rear_port_position'],
]
ordering = ('device', '_name')
unique_together = (
('device', 'name'),
('rear_port', 'rear_port_position'),
)
def __str__(self):
return self.name
@@ -831,6 +876,11 @@ class RearPort(CableTermination, ComponentModel):
name = models.CharField(
max_length=64
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
type = models.CharField(
max_length=50,
choices=PortTypeChoices
@@ -839,17 +889,14 @@ class RearPort(CableTermination, ComponentModel):
default=1,
validators=[MinValueValidator(1), MaxValueValidator(64)]
)
is_path_endpoint = False
objects = NaturalOrderingManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = ['device', 'name', 'type', 'positions', 'description']
is_path_endpoint = False
class Meta:
ordering = ['device', 'name']
unique_together = ['device', 'name']
ordering = ('device', '_name')
unique_together = ('device', 'name')
def __str__(self):
return self.name
@@ -881,6 +928,11 @@ class DeviceBay(ComponentModel):
max_length=50,
verbose_name='Name'
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
installed_device = models.OneToOneField(
to='dcim.Device',
on_delete=models.SET_NULL,
@@ -888,15 +940,13 @@ class DeviceBay(ComponentModel):
blank=True,
null=True
)
objects = NaturalOrderingManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = ['device', 'name', 'installed_device', 'description']
class Meta:
ordering = ['device', 'name']
unique_together = ['device', 'name']
ordering = ('device', '_name')
unique_together = ('device', 'name')
def __str__(self):
return '{} - {}'.format(self.device.name, self.name)
@@ -960,6 +1010,11 @@ class InventoryItem(ComponentModel):
max_length=50,
verbose_name='Name'
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
manufacturer = models.ForeignKey(
to='dcim.Manufacturer',
on_delete=models.PROTECT,
@@ -997,14 +1052,14 @@ class InventoryItem(ComponentModel):
]
class Meta:
ordering = ['device__id', 'parent__id', 'name']
unique_together = ['device', 'parent', 'name']
ordering = ('device__id', 'parent__id', '_name')
unique_together = ('device', 'parent', 'name')
def __str__(self):
return self.name
def get_absolute_url(self):
return self.device.get_absolute_url()
return reverse('dcim:device_inventory', kwargs={'pk': self.device.pk})
def to_csv(self):
return (

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 %}
@@ -200,6 +200,11 @@ def get_component_template_actions(model_name):
<i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
</a>
{{% endif %}}
{{% if perms.dcim.delete_{model_name} %}}
<a href="{{% url 'dcim:{model_name}_delete' pk=record.pk %}}?return_url={{{{ request.path }}}}" class="btn btn-xs btn-danger">
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</a>
{{% endif %}}
""".format(model_name=model_name).strip()
@@ -229,7 +234,7 @@ class RegionTable(BaseTable):
class SiteTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn(order_by=('_nat1', '_nat2', '_nat3'))
name = tables.LinkColumn(order_by=('_name',))
status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status')
region = tables.TemplateColumn(template_code=SITE_REGION_LINK)
tenant = tables.TemplateColumn(template_code=COL_TENANT)
@@ -291,7 +296,7 @@ class RackRoleTable(BaseTable):
class RackTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn(order_by=('_nat1', '_nat2', '_nat3'))
name = tables.LinkColumn(order_by=('_name',))
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
tenant = tables.TemplateColumn(template_code=COL_TENANT)
@@ -409,6 +414,7 @@ class DeviceTypeTable(BaseTable):
class ConsolePortTemplateTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(order_by=('_name',))
actions = tables.TemplateColumn(
template_code=get_component_template_actions('consoleporttemplate'),
attrs={'td': {'class': 'text-right noprint'}},
@@ -432,6 +438,7 @@ class ConsolePortImportTable(BaseTable):
class ConsoleServerPortTemplateTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(order_by=('_name',))
actions = tables.TemplateColumn(
template_code=get_component_template_actions('consoleserverporttemplate'),
attrs={'td': {'class': 'text-right noprint'}},
@@ -440,7 +447,7 @@ class ConsoleServerPortTemplateTable(BaseTable):
class Meta(BaseTable.Meta):
model = ConsoleServerPortTemplate
fields = ('pk', 'name', 'actions')
fields = ('pk', 'name', 'type', 'actions')
empty_text = "None"
@@ -455,6 +462,7 @@ class ConsoleServerPortImportTable(BaseTable):
class PowerPortTemplateTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(order_by=('_name',))
actions = tables.TemplateColumn(
template_code=get_component_template_actions('powerporttemplate'),
attrs={'td': {'class': 'text-right noprint'}},
@@ -478,6 +486,7 @@ class PowerPortImportTable(BaseTable):
class PowerOutletTemplateTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(order_by=('_name',))
actions = tables.TemplateColumn(
template_code=get_component_template_actions('poweroutlettemplate'),
attrs={'td': {'class': 'text-right noprint'}},
@@ -526,6 +535,7 @@ class InterfaceImportTable(BaseTable):
class FrontPortTemplateTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(order_by=('_name',))
rear_port_position = tables.Column(
verbose_name='Position'
)
@@ -552,6 +562,7 @@ class FrontPortImportTable(BaseTable):
class RearPortTemplateTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(order_by=('_name',))
actions = tables.TemplateColumn(
template_code=get_component_template_actions('rearporttemplate'),
attrs={'td': {'class': 'text-right noprint'}},
@@ -575,6 +586,7 @@ class RearPortImportTable(BaseTable):
class DeviceBayTemplateTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(order_by=('_name',))
actions = tables.TemplateColumn(
template_code=get_component_template_actions('devicebaytemplate'),
attrs={'td': {'class': 'text-right noprint'}},
@@ -654,7 +666,7 @@ class PlatformTable(BaseTable):
class DeviceTable(BaseTable):
pk = ToggleColumn()
name = tables.TemplateColumn(
order_by=('_nat1', '_nat2', '_nat3'),
order_by=('_name',),
template_code=DEVICE_LINK
)
status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status')
@@ -704,6 +716,7 @@ class DeviceImportTable(BaseTable):
class DeviceComponentDetailTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(order_by=('_name',))
cable = tables.LinkColumn()
class Meta(BaseTable.Meta):
@@ -713,6 +726,7 @@ class DeviceComponentDetailTable(BaseTable):
class ConsolePortTable(BaseTable):
name = tables.Column(order_by=('_name',))
class Meta(BaseTable.Meta):
model = ConsolePort
@@ -727,6 +741,7 @@ class ConsolePortDetailTable(DeviceComponentDetailTable):
class ConsoleServerPortTable(BaseTable):
name = tables.Column(order_by=('_name',))
class Meta(BaseTable.Meta):
model = ConsoleServerPort
@@ -741,6 +756,7 @@ class ConsoleServerPortDetailTable(DeviceComponentDetailTable):
class PowerPortTable(BaseTable):
name = tables.Column(order_by=('_name',))
class Meta(BaseTable.Meta):
model = PowerPort
@@ -755,6 +771,7 @@ class PowerPortDetailTable(DeviceComponentDetailTable):
class PowerOutletTable(BaseTable):
name = tables.Column(order_by=('_name',))
class Meta(BaseTable.Meta):
model = PowerOutlet
@@ -777,14 +794,17 @@ 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):
name = tables.Column(order_by=('_name',))
class Meta(BaseTable.Meta):
model = FrontPort
@@ -800,6 +820,7 @@ class FrontPortDetailTable(DeviceComponentDetailTable):
class RearPortTable(BaseTable):
name = tables.Column(order_by=('_name',))
class Meta(BaseTable.Meta):
model = RearPort
@@ -815,6 +836,7 @@ class RearPortDetailTable(DeviceComponentDetailTable):
class DeviceBayTable(BaseTable):
name = tables.Column(order_by=('_name',))
class Meta(BaseTable.Meta):
model = DeviceBay

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

@@ -11,7 +11,7 @@ from dcim.choices import *
from dcim.constants import *
from dcim.models import *
from ipam.models import VLAN
from utilities.testing import StandardTestCases
from utilities.testing import ViewTestCases
def create_test_device(name):
@@ -27,14 +27,9 @@ def create_test_device(name):
return device
class RegionTestCase(StandardTestCases.Views):
class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = Region
# Disable inapplicable tests
test_get_object = None
test_delete_object = None
test_bulk_edit_objects = None
@classmethod
def setUpTestData(cls):
@@ -61,7 +56,7 @@ class RegionTestCase(StandardTestCases.Views):
)
class SiteTestCase(StandardTestCases.Views):
class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = Site
@classmethod
@@ -118,14 +113,9 @@ class SiteTestCase(StandardTestCases.Views):
}
class RackGroupTestCase(StandardTestCases.Views):
class RackGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = RackGroup
# Disable inapplicable tests
test_get_object = None
test_delete_object = None
test_bulk_edit_objects = None
@classmethod
def setUpTestData(cls):
@@ -152,14 +142,9 @@ class RackGroupTestCase(StandardTestCases.Views):
)
class RackRoleTestCase(StandardTestCases.Views):
class RackRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = RackRole
# Disable inapplicable tests
test_get_object = None
test_delete_object = None
test_bulk_edit_objects = None
@classmethod
def setUpTestData(cls):
@@ -184,7 +169,7 @@ class RackRoleTestCase(StandardTestCases.Views):
)
class RackReservationTestCase(StandardTestCases.Views):
class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = RackReservation
# Disable inapplicable tests
@@ -226,7 +211,7 @@ class RackReservationTestCase(StandardTestCases.Views):
}
class RackTestCase(StandardTestCases.Views):
class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = Rack
@classmethod
@@ -302,14 +287,9 @@ class RackTestCase(StandardTestCases.Views):
}
class ManufacturerTestCase(StandardTestCases.Views):
class ManufacturerTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = Manufacturer
# Disable inapplicable tests
test_get_object = None
test_delete_object = None
test_bulk_edit_objects = None
@classmethod
def setUpTestData(cls):
@@ -332,7 +312,7 @@ class ManufacturerTestCase(StandardTestCases.Views):
)
class DeviceTypeTestCase(StandardTestCases.Views):
class DeviceTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = DeviceType
@classmethod
@@ -524,14 +504,318 @@ device-bays:
self.assertEqual(data[0]['model'], 'Device Type 1')
class DeviceRoleTestCase(StandardTestCases.Views):
model = DeviceRole
#
# DeviceType components
#
# Disable inapplicable tests
test_get_object = None
test_delete_object = None
class ConsolePortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
model = ConsolePortTemplate
@classmethod
def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
devicetypes = (
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
)
DeviceType.objects.bulk_create(devicetypes)
ConsolePortTemplate.objects.bulk_create((
ConsolePortTemplate(device_type=devicetypes[0], name='Console Port Template 1'),
ConsolePortTemplate(device_type=devicetypes[0], name='Console Port Template 2'),
ConsolePortTemplate(device_type=devicetypes[0], name='Console Port Template 3'),
))
cls.form_data = {
'device_type': devicetypes[1].pk,
'name': 'Console Port Template X',
'type': ConsolePortTypeChoices.TYPE_RJ45,
}
cls.bulk_create_data = {
'device_type': devicetypes[1].pk,
'name_pattern': 'Console Port Template [4-6]',
'type': ConsolePortTypeChoices.TYPE_RJ45,
}
cls.bulk_edit_data = {
'type': ConsolePortTypeChoices.TYPE_RJ45,
}
class ConsoleServerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
model = ConsoleServerPortTemplate
@classmethod
def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
devicetypes = (
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
)
DeviceType.objects.bulk_create(devicetypes)
ConsoleServerPortTemplate.objects.bulk_create((
ConsoleServerPortTemplate(device_type=devicetypes[0], name='Console Server Port Template 1'),
ConsoleServerPortTemplate(device_type=devicetypes[0], name='Console Server Port Template 2'),
ConsoleServerPortTemplate(device_type=devicetypes[0], name='Console Server Port Template 3'),
))
cls.form_data = {
'device_type': devicetypes[1].pk,
'name': 'Console Server Port Template X',
'type': ConsolePortTypeChoices.TYPE_RJ45,
}
cls.bulk_create_data = {
'device_type': devicetypes[1].pk,
'name_pattern': 'Console Server Port Template [4-6]',
'type': ConsolePortTypeChoices.TYPE_RJ45,
}
cls.bulk_edit_data = {
'type': ConsolePortTypeChoices.TYPE_RJ45,
}
class PowerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
model = PowerPortTemplate
@classmethod
def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
devicetypes = (
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
)
DeviceType.objects.bulk_create(devicetypes)
PowerPortTemplate.objects.bulk_create((
PowerPortTemplate(device_type=devicetypes[0], name='Power Port Template 1'),
PowerPortTemplate(device_type=devicetypes[0], name='Power Port Template 2'),
PowerPortTemplate(device_type=devicetypes[0], name='Power Port Template 3'),
))
cls.form_data = {
'device_type': devicetypes[1].pk,
'name': 'Power Port Template X',
'type': PowerPortTypeChoices.TYPE_IEC_C14,
'maximum_draw': 100,
'allocated_draw': 50,
}
cls.bulk_create_data = {
'device_type': devicetypes[1].pk,
'name_pattern': 'Power Port Template [4-6]',
'type': PowerPortTypeChoices.TYPE_IEC_C14,
'maximum_draw': 100,
'allocated_draw': 50,
}
cls.bulk_edit_data = {
'type': PowerPortTypeChoices.TYPE_IEC_C14,
'maximum_draw': 100,
'allocated_draw': 50,
}
class PowerOutletTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
model = PowerOutletTemplate
@classmethod
def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
PowerOutletTemplate.objects.bulk_create((
PowerOutletTemplate(device_type=devicetype, name='Power Outlet Template 1'),
PowerOutletTemplate(device_type=devicetype, name='Power Outlet Template 2'),
PowerOutletTemplate(device_type=devicetype, name='Power Outlet Template 3'),
))
powerports = (
PowerPortTemplate(device_type=devicetype, name='Power Port Template 1'),
)
PowerPortTemplate.objects.bulk_create(powerports)
cls.form_data = {
'device_type': devicetype.pk,
'name': 'Power Outlet Template X',
'type': PowerOutletTypeChoices.TYPE_IEC_C13,
'power_port': powerports[0].pk,
'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
}
cls.bulk_create_data = {
'device_type': devicetype.pk,
'name_pattern': 'Power Outlet Template [4-6]',
'type': PowerOutletTypeChoices.TYPE_IEC_C13,
'power_port': powerports[0].pk,
'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
}
cls.bulk_edit_data = {
'type': PowerOutletTypeChoices.TYPE_IEC_C13,
'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
}
class InterfaceTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
model = InterfaceTemplate
@classmethod
def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
devicetypes = (
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
)
DeviceType.objects.bulk_create(devicetypes)
InterfaceTemplate.objects.bulk_create((
InterfaceTemplate(device_type=devicetypes[0], name='Interface Template 1'),
InterfaceTemplate(device_type=devicetypes[0], name='Interface Template 2'),
InterfaceTemplate(device_type=devicetypes[0], name='Interface Template 3'),
))
cls.form_data = {
'device_type': devicetypes[1].pk,
'name': 'Interface Template X',
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
'mgmt_only': True,
}
cls.bulk_create_data = {
'device_type': devicetypes[1].pk,
'name_pattern': 'Interface Template [4-6]',
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
'mgmt_only': True,
}
cls.bulk_edit_data = {
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
'mgmt_only': True,
}
class FrontPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
model = FrontPortTemplate
@classmethod
def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
rearports = (
RearPortTemplate(device_type=devicetype, name='Rear Port Template 1'),
RearPortTemplate(device_type=devicetype, name='Rear Port Template 2'),
RearPortTemplate(device_type=devicetype, name='Rear Port Template 3'),
RearPortTemplate(device_type=devicetype, name='Rear Port Template 4'),
RearPortTemplate(device_type=devicetype, name='Rear Port Template 5'),
RearPortTemplate(device_type=devicetype, name='Rear Port Template 6'),
)
RearPortTemplate.objects.bulk_create(rearports)
FrontPortTemplate.objects.bulk_create((
FrontPortTemplate(device_type=devicetype, name='Front Port Template 1', rear_port=rearports[0], rear_port_position=1),
FrontPortTemplate(device_type=devicetype, name='Front Port Template 2', rear_port=rearports[1], rear_port_position=1),
FrontPortTemplate(device_type=devicetype, name='Front Port Template 3', rear_port=rearports[2], rear_port_position=1),
))
cls.form_data = {
'device_type': devicetype.pk,
'name': 'Front Port X',
'type': PortTypeChoices.TYPE_8P8C,
'rear_port': rearports[3].pk,
'rear_port_position': 1,
}
cls.bulk_create_data = {
'device_type': devicetype.pk,
'name_pattern': 'Front Port [4-6]',
'type': PortTypeChoices.TYPE_8P8C,
'rear_port_set': [
'{}:1'.format(rp.pk) for rp in rearports[3:6]
],
}
cls.bulk_edit_data = {
'type': PortTypeChoices.TYPE_8P8C,
}
class RearPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
model = RearPortTemplate
@classmethod
def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
devicetypes = (
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
)
DeviceType.objects.bulk_create(devicetypes)
RearPortTemplate.objects.bulk_create((
RearPortTemplate(device_type=devicetypes[0], name='Rear Port Template 1'),
RearPortTemplate(device_type=devicetypes[0], name='Rear Port Template 2'),
RearPortTemplate(device_type=devicetypes[0], name='Rear Port Template 3'),
))
cls.form_data = {
'device_type': devicetypes[1].pk,
'name': 'Rear Port Template X',
'type': PortTypeChoices.TYPE_8P8C,
'positions': 2,
}
cls.bulk_create_data = {
'device_type': devicetypes[1].pk,
'name_pattern': 'Rear Port Template [4-6]',
'type': PortTypeChoices.TYPE_8P8C,
'positions': 2,
}
cls.bulk_edit_data = {
'type': PortTypeChoices.TYPE_8P8C,
}
class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
model = DeviceBayTemplate
# Disable inapplicable views
test_bulk_edit_objects = None
@classmethod
def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
devicetypes = (
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
)
DeviceType.objects.bulk_create(devicetypes)
DeviceBayTemplate.objects.bulk_create((
DeviceBayTemplate(device_type=devicetypes[0], name='Device Bay Template 1'),
DeviceBayTemplate(device_type=devicetypes[0], name='Device Bay Template 2'),
DeviceBayTemplate(device_type=devicetypes[0], name='Device Bay Template 3'),
))
cls.form_data = {
'device_type': devicetypes[1].pk,
'name': 'Device Bay Template X',
}
cls.bulk_create_data = {
'device_type': devicetypes[1].pk,
'name_pattern': 'Device Bay Template [4-6]',
}
class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = DeviceRole
@classmethod
def setUpTestData(cls):
@@ -557,14 +841,9 @@ class DeviceRoleTestCase(StandardTestCases.Views):
)
class PlatformTestCase(StandardTestCases.Views):
class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = Platform
# Disable inapplicable tests
test_get_object = None
test_delete_object = None
test_bulk_edit_objects = None
@classmethod
def setUpTestData(cls):
@@ -592,7 +871,7 @@ class PlatformTestCase(StandardTestCases.Views):
)
class DeviceTestCase(StandardTestCases.Views):
class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = Device
@classmethod
@@ -677,17 +956,9 @@ class DeviceTestCase(StandardTestCases.Views):
}
class ConsolePortTestCase(StandardTestCases.Views):
class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = ConsolePort
# Disable inapplicable views
test_get_object = None
test_bulk_edit_objects = None
# TODO
test_create_object = None
test_bulk_delete_objects = None
@classmethod
def setUpTestData(cls):
device = create_test_device('Device 1')
@@ -704,11 +975,19 @@ class ConsolePortTestCase(StandardTestCases.Views):
'type': ConsolePortTypeChoices.TYPE_RJ45,
'description': 'A console port',
'tags': 'Alpha,Bravo,Charlie',
}
# Extraneous model fields
'cable': None,
'connected_endpoint': None,
'connection_status': None,
cls.bulk_create_data = {
'device': device.pk,
'name_pattern': 'Console Port [4-6]',
'type': ConsolePortTypeChoices.TYPE_RJ45,
'description': 'A console port',
'tags': 'Alpha,Bravo,Charlie',
}
cls.bulk_edit_data = {
'type': ConsolePortTypeChoices.TYPE_RJ45,
'description': 'New description',
}
cls.csv_data = (
@@ -719,17 +998,9 @@ class ConsolePortTestCase(StandardTestCases.Views):
)
class ConsoleServerPortTestCase(StandardTestCases.Views):
class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = ConsoleServerPort
# Disable inapplicable views
test_get_object = None
# TODO
test_create_object = None
test_bulk_edit_objects = None
test_bulk_delete_objects = None
@classmethod
def setUpTestData(cls):
device = create_test_device('Device 1')
@@ -746,10 +1017,20 @@ class ConsoleServerPortTestCase(StandardTestCases.Views):
'type': ConsolePortTypeChoices.TYPE_RJ45,
'description': 'A console server port',
'tags': 'Alpha,Bravo,Charlie',
}
# Extraneous model fields
'cable': None,
'connection_status': None,
cls.bulk_create_data = {
'device': device.pk,
'name_pattern': 'Console Server Port [4-6]',
'type': ConsolePortTypeChoices.TYPE_RJ45,
'description': 'A console server port',
'tags': 'Alpha,Bravo,Charlie',
}
cls.bulk_edit_data = {
'device': device.pk,
'type': ConsolePortTypeChoices.TYPE_RJ45,
'description': 'New description',
}
cls.csv_data = (
@@ -760,17 +1041,9 @@ class ConsoleServerPortTestCase(StandardTestCases.Views):
)
class PowerPortTestCase(StandardTestCases.Views):
class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = PowerPort
# Disable inapplicable views
test_get_object = None
test_bulk_edit_objects = None
# TODO
test_create_object = None
test_bulk_delete_objects = None
@classmethod
def setUpTestData(cls):
device = create_test_device('Device 1')
@@ -789,10 +1062,23 @@ class PowerPortTestCase(StandardTestCases.Views):
'allocated_draw': 50,
'description': 'A power port',
'tags': 'Alpha,Bravo,Charlie',
}
# Extraneous model fields
'cable': None,
'connection_status': None,
cls.bulk_create_data = {
'device': device.pk,
'name_pattern': 'Power Port [4-6]]',
'type': PowerPortTypeChoices.TYPE_IEC_C14,
'maximum_draw': 100,
'allocated_draw': 50,
'description': 'A power port',
'tags': 'Alpha,Bravo,Charlie',
}
cls.bulk_edit_data = {
'type': PowerPortTypeChoices.TYPE_IEC_C14,
'maximum_draw': 100,
'allocated_draw': 50,
'description': 'New description',
}
cls.csv_data = (
@@ -803,17 +1089,9 @@ class PowerPortTestCase(StandardTestCases.Views):
)
class PowerOutletTestCase(StandardTestCases.Views):
class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = PowerOutlet
# Disable inapplicable views
test_get_object = None
# TODO
test_create_object = None
test_bulk_edit_objects = None
test_bulk_delete_objects = None
@classmethod
def setUpTestData(cls):
device = create_test_device('Device 1')
@@ -838,10 +1116,24 @@ class PowerOutletTestCase(StandardTestCases.Views):
'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
'description': 'A power outlet',
'tags': 'Alpha,Bravo,Charlie',
}
# Extraneous model fields
'cable': None,
'connection_status': None,
cls.bulk_create_data = {
'device': device.pk,
'name_pattern': 'Power Outlet [4-6]',
'type': PowerOutletTypeChoices.TYPE_IEC_C13,
'power_port': powerports[1].pk,
'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
'description': 'A power outlet',
'tags': 'Alpha,Bravo,Charlie',
}
cls.bulk_edit_data = {
'device': device.pk,
'type': PowerOutletTypeChoices.TYPE_IEC_C13,
'power_port': powerports[1].pk,
'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
'description': 'New description',
}
cls.csv_data = (
@@ -852,23 +1144,23 @@ class PowerOutletTestCase(StandardTestCases.Views):
)
class InterfaceTestCase(StandardTestCases.Views):
class InterfaceTestCase(
ViewTestCases.GetObjectViewTestCase,
ViewTestCases.DeviceComponentViewTestCase,
):
model = Interface
# TODO
test_create_object = None
test_bulk_edit_objects = None
test_bulk_delete_objects = None
@classmethod
def setUpTestData(cls):
device = create_test_device('Device 1')
Interface.objects.bulk_create([
interfaces = (
Interface(device=device, name='Interface 1'),
Interface(device=device, name='Interface 2'),
Interface(device=device, name='Interface 3'),
])
Interface(device=device, name='LAG', type=InterfaceTypeChoices.TYPE_LAG),
)
Interface.objects.bulk_create(interfaces)
vlans = (
VLAN(vid=1, name='VLAN1', site=device.site),
@@ -884,7 +1176,38 @@ class InterfaceTestCase(StandardTestCases.Views):
'name': 'Interface X',
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
'enabled': False,
'lag': None,
'lag': interfaces[3].pk,
'mac_address': EUI('01:02:03:04:05:06'),
'mtu': 2000,
'mgmt_only': True,
'description': 'A front port',
'mode': InterfaceModeChoices.MODE_TAGGED,
'untagged_vlan': vlans[0].pk,
'tagged_vlans': [v.pk for v in vlans[1:4]],
'tags': 'Alpha,Bravo,Charlie',
}
cls.bulk_create_data = {
'device': device.pk,
'name_pattern': 'Interface [4-6]',
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
'enabled': False,
'lag': interfaces[3].pk,
'mac_address': EUI('01:02:03:04:05:06'),
'mtu': 2000,
'mgmt_only': True,
'description': 'A front port',
'mode': InterfaceModeChoices.MODE_TAGGED,
'untagged_vlan': vlans[0].pk,
'tagged_vlans': [v.pk for v in vlans[1:4]],
'tags': 'Alpha,Bravo,Charlie',
}
cls.bulk_edit_data = {
'device': device.pk,
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
'enabled': False,
'lag': interfaces[3].pk,
'mac_address': EUI('01:02:03:04:05:06'),
'mtu': 2000,
'mgmt_only': True,
@@ -892,11 +1215,6 @@ class InterfaceTestCase(StandardTestCases.Views):
'mode': InterfaceModeChoices.MODE_TAGGED,
'untagged_vlan': vlans[0].pk,
'tagged_vlans': [v.pk for v in vlans[1:4]],
'tags': 'Alpha,Bravo,Charlie',
# Extraneous model fields
'cable': None,
'connection_status': None,
}
cls.csv_data = (
@@ -907,17 +1225,9 @@ class InterfaceTestCase(StandardTestCases.Views):
)
class FrontPortTestCase(StandardTestCases.Views):
class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = FrontPort
# Disable inapplicable views
test_get_object = None
# TODO
test_create_object = None
test_bulk_edit_objects = None
test_bulk_delete_objects = None
@classmethod
def setUpTestData(cls):
device = create_test_device('Device 1')
@@ -946,9 +1256,22 @@ class FrontPortTestCase(StandardTestCases.Views):
'rear_port_position': 1,
'description': 'New description',
'tags': 'Alpha,Bravo,Charlie',
}
# Extraneous model fields
'cable': None,
cls.bulk_create_data = {
'device': device.pk,
'name_pattern': 'Front Port [4-6]',
'type': PortTypeChoices.TYPE_8P8C,
'rear_port_set': [
'{}:1'.format(rp.pk) for rp in rearports[3:6]
],
'description': 'New description',
'tags': 'Alpha,Bravo,Charlie',
}
cls.bulk_edit_data = {
'type': PortTypeChoices.TYPE_8P8C,
'description': 'New description',
}
cls.csv_data = (
@@ -959,17 +1282,9 @@ class FrontPortTestCase(StandardTestCases.Views):
)
class RearPortTestCase(StandardTestCases.Views):
class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = RearPort
# Disable inapplicable views
test_get_object = None
# TODO
test_create_object = None
test_bulk_edit_objects = None
test_bulk_delete_objects = None
@classmethod
def setUpTestData(cls):
device = create_test_device('Device 1')
@@ -985,11 +1300,22 @@ class RearPortTestCase(StandardTestCases.Views):
'name': 'Rear Port X',
'type': PortTypeChoices.TYPE_8P8C,
'positions': 3,
'description': 'New description',
'description': 'A rear port',
'tags': 'Alpha,Bravo,Charlie',
}
# Extraneous model fields
'cable': None,
cls.bulk_create_data = {
'device': device.pk,
'name_pattern': 'Rear Port [4-6]',
'type': PortTypeChoices.TYPE_8P8C,
'positions': 3,
'description': 'A rear port',
'tags': 'Alpha,Bravo,Charlie',
}
cls.bulk_edit_data = {
'type': PortTypeChoices.TYPE_8P8C,
'description': 'New description',
}
cls.csv_data = (
@@ -1000,16 +1326,11 @@ class RearPortTestCase(StandardTestCases.Views):
)
class DeviceBayTestCase(StandardTestCases.Views):
class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = DeviceBay
# Disable inapplicable views
test_get_object = None
# TODO
test_create_object = None
test_bulk_edit_objects = None
test_bulk_delete_objects = None
@classmethod
def setUpTestData(cls):
@@ -1030,9 +1351,13 @@ class DeviceBayTestCase(StandardTestCases.Views):
'name': 'Device Bay X',
'description': 'A device bay',
'tags': 'Alpha,Bravo,Charlie',
}
# Extraneous model fields
'installed_device': None,
cls.bulk_create_data = {
'device': device2.pk,
'name_pattern': 'Device Bay [4-6]',
'description': 'A device bay',
'tags': 'Alpha,Bravo,Charlie',
}
cls.csv_data = (
@@ -1043,15 +1368,9 @@ class DeviceBayTestCase(StandardTestCases.Views):
)
class InventoryItemTestCase(StandardTestCases.Views):
class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = InventoryItem
# Disable inapplicable views
test_get_object = None
# TODO
test_create_object = None
@classmethod
def setUpTestData(cls):
device = create_test_device('Device 1')
@@ -1076,12 +1395,17 @@ class InventoryItemTestCase(StandardTestCases.Views):
'tags': 'Alpha,Bravo,Charlie',
}
cls.csv_data = (
"device,name",
"Device 1,Inventory Item 4",
"Device 1,Inventory Item 5",
"Device 1,Inventory Item 6",
)
cls.bulk_create_data = {
'device': device.pk,
'name_pattern': 'Inventory Item [4-6]',
'manufacturer': manufacturer.pk,
'parent': None,
'discovered': False,
'part_id': '123456',
'serial': '123ABC',
'description': 'An inventory item',
'tags': 'Alpha,Bravo,Charlie',
}
cls.bulk_edit_data = {
'device': device.pk,
@@ -1090,8 +1414,15 @@ class InventoryItemTestCase(StandardTestCases.Views):
'description': 'New description',
}
cls.csv_data = (
"device,name",
"Device 1,Inventory Item 4",
"Device 1,Inventory Item 5",
"Device 1,Inventory Item 6",
)
class CableTestCase(StandardTestCases.Views):
class CableTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = Cable
# TODO: Creation URL needs termination context
@@ -1165,7 +1496,7 @@ class CableTestCase(StandardTestCases.Views):
}
class VirtualChassisTestCase(StandardTestCases.Views):
class VirtualChassisTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = VirtualChassis
# Disable inapplicable tests
@@ -1219,7 +1550,7 @@ class VirtualChassisTestCase(StandardTestCases.Views):
Device.objects.filter(pk=device6.pk).update(virtual_chassis=vc3, vc_position=2)
class PowerPanelTestCase(StandardTestCases.Views):
class PowerPanelTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = PowerPanel
# Disable inapplicable tests
@@ -1260,7 +1591,7 @@ class PowerPanelTestCase(StandardTestCases.Views):
)
class PowerFeedTestCase(StandardTestCases.Views):
class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = PowerFeed
@classmethod

View File

@@ -14,317 +14,338 @@ app_name = 'dcim'
urlpatterns = [
# Regions
path(r'regions/', views.RegionListView.as_view(), name='region_list'),
path(r'regions/add/', views.RegionCreateView.as_view(), name='region_add'),
path(r'regions/import/', views.RegionBulkImportView.as_view(), name='region_import'),
path(r'regions/delete/', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'),
path(r'regions/<int:pk>/edit/', views.RegionEditView.as_view(), name='region_edit'),
path(r'regions/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='region_changelog', kwargs={'model': Region}),
path('regions/', views.RegionListView.as_view(), name='region_list'),
path('regions/add/', views.RegionCreateView.as_view(), name='region_add'),
path('regions/import/', views.RegionBulkImportView.as_view(), name='region_import'),
path('regions/delete/', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'),
path('regions/<int:pk>/edit/', views.RegionEditView.as_view(), name='region_edit'),
path('regions/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='region_changelog', kwargs={'model': Region}),
# Sites
path(r'sites/', views.SiteListView.as_view(), name='site_list'),
path(r'sites/add/', views.SiteCreateView.as_view(), name='site_add'),
path(r'sites/import/', views.SiteBulkImportView.as_view(), name='site_import'),
path(r'sites/edit/', views.SiteBulkEditView.as_view(), name='site_bulk_edit'),
path(r'sites/delete/', views.SiteBulkDeleteView.as_view(), name='site_bulk_delete'),
path(r'sites/<slug:slug>/', views.SiteView.as_view(), name='site'),
path(r'sites/<slug:slug>/edit/', views.SiteEditView.as_view(), name='site_edit'),
path(r'sites/<slug:slug>/delete/', views.SiteDeleteView.as_view(), name='site_delete'),
path(r'sites/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}),
path(r'sites/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}),
path('sites/', views.SiteListView.as_view(), name='site_list'),
path('sites/add/', views.SiteCreateView.as_view(), name='site_add'),
path('sites/import/', views.SiteBulkImportView.as_view(), name='site_import'),
path('sites/edit/', views.SiteBulkEditView.as_view(), name='site_bulk_edit'),
path('sites/delete/', views.SiteBulkDeleteView.as_view(), name='site_bulk_delete'),
path('sites/<slug:slug>/', views.SiteView.as_view(), name='site'),
path('sites/<slug:slug>/edit/', views.SiteEditView.as_view(), name='site_edit'),
path('sites/<slug:slug>/delete/', views.SiteDeleteView.as_view(), name='site_delete'),
path('sites/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}),
path('sites/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}),
# Rack groups
path(r'rack-groups/', views.RackGroupListView.as_view(), name='rackgroup_list'),
path(r'rack-groups/add/', views.RackGroupCreateView.as_view(), name='rackgroup_add'),
path(r'rack-groups/import/', views.RackGroupBulkImportView.as_view(), name='rackgroup_import'),
path(r'rack-groups/delete/', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'),
path(r'rack-groups/<int:pk>/edit/', views.RackGroupEditView.as_view(), name='rackgroup_edit'),
path(r'rack-groups/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rackgroup_changelog', kwargs={'model': RackGroup}),
path('rack-groups/', views.RackGroupListView.as_view(), name='rackgroup_list'),
path('rack-groups/add/', views.RackGroupCreateView.as_view(), name='rackgroup_add'),
path('rack-groups/import/', views.RackGroupBulkImportView.as_view(), name='rackgroup_import'),
path('rack-groups/delete/', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'),
path('rack-groups/<int:pk>/edit/', views.RackGroupEditView.as_view(), name='rackgroup_edit'),
path('rack-groups/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rackgroup_changelog', kwargs={'model': RackGroup}),
# Rack roles
path(r'rack-roles/', views.RackRoleListView.as_view(), name='rackrole_list'),
path(r'rack-roles/add/', views.RackRoleCreateView.as_view(), name='rackrole_add'),
path(r'rack-roles/import/', views.RackRoleBulkImportView.as_view(), name='rackrole_import'),
path(r'rack-roles/delete/', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'),
path(r'rack-roles/<int:pk>/edit/', views.RackRoleEditView.as_view(), name='rackrole_edit'),
path(r'rack-roles/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rackrole_changelog', kwargs={'model': RackRole}),
path('rack-roles/', views.RackRoleListView.as_view(), name='rackrole_list'),
path('rack-roles/add/', views.RackRoleCreateView.as_view(), name='rackrole_add'),
path('rack-roles/import/', views.RackRoleBulkImportView.as_view(), name='rackrole_import'),
path('rack-roles/delete/', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'),
path('rack-roles/<int:pk>/edit/', views.RackRoleEditView.as_view(), name='rackrole_edit'),
path('rack-roles/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rackrole_changelog', kwargs={'model': RackRole}),
# Rack reservations
path(r'rack-reservations/', views.RackReservationListView.as_view(), name='rackreservation_list'),
path(r'rack-reservations/edit/', views.RackReservationBulkEditView.as_view(), name='rackreservation_bulk_edit'),
path(r'rack-reservations/delete/', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'),
path(r'rack-reservations/<int:pk>/edit/', views.RackReservationEditView.as_view(), name='rackreservation_edit'),
path(r'rack-reservations/<int:pk>/delete/', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'),
path(r'rack-reservations/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rackreservation_changelog', kwargs={'model': RackReservation}),
path('rack-reservations/', views.RackReservationListView.as_view(), name='rackreservation_list'),
path('rack-reservations/edit/', views.RackReservationBulkEditView.as_view(), name='rackreservation_bulk_edit'),
path('rack-reservations/delete/', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'),
path('rack-reservations/<int:pk>/edit/', views.RackReservationEditView.as_view(), name='rackreservation_edit'),
path('rack-reservations/<int:pk>/delete/', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'),
path('rack-reservations/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rackreservation_changelog', kwargs={'model': RackReservation}),
# Racks
path(r'racks/', views.RackListView.as_view(), name='rack_list'),
path(r'rack-elevations/', views.RackElevationListView.as_view(), name='rack_elevation_list'),
path(r'racks/add/', views.RackCreateView.as_view(), name='rack_add'),
path(r'racks/import/', views.RackBulkImportView.as_view(), name='rack_import'),
path(r'racks/edit/', views.RackBulkEditView.as_view(), name='rack_bulk_edit'),
path(r'racks/delete/', views.RackBulkDeleteView.as_view(), name='rack_bulk_delete'),
path(r'racks/<int:pk>/', views.RackView.as_view(), name='rack'),
path(r'racks/<int:pk>/edit/', views.RackEditView.as_view(), name='rack_edit'),
path(r'racks/<int:pk>/delete/', views.RackDeleteView.as_view(), name='rack_delete'),
path(r'racks/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rack_changelog', kwargs={'model': Rack}),
path(r'racks/<int:rack>/reservations/add/', views.RackReservationCreateView.as_view(), name='rack_add_reservation'),
path(r'racks/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}),
path('racks/', views.RackListView.as_view(), name='rack_list'),
path('rack-elevations/', views.RackElevationListView.as_view(), name='rack_elevation_list'),
path('racks/add/', views.RackCreateView.as_view(), name='rack_add'),
path('racks/import/', views.RackBulkImportView.as_view(), name='rack_import'),
path('racks/edit/', views.RackBulkEditView.as_view(), name='rack_bulk_edit'),
path('racks/delete/', views.RackBulkDeleteView.as_view(), name='rack_bulk_delete'),
path('racks/<int:pk>/', views.RackView.as_view(), name='rack'),
path('racks/<int:pk>/edit/', views.RackEditView.as_view(), name='rack_edit'),
path('racks/<int:pk>/delete/', views.RackDeleteView.as_view(), name='rack_delete'),
path('racks/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rack_changelog', kwargs={'model': Rack}),
path('racks/<int:rack>/reservations/add/', views.RackReservationCreateView.as_view(), name='rack_add_reservation'),
path('racks/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}),
# Manufacturers
path(r'manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'),
path(r'manufacturers/add/', views.ManufacturerCreateView.as_view(), name='manufacturer_add'),
path(r'manufacturers/import/', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'),
path(r'manufacturers/delete/', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'),
path(r'manufacturers/<slug:slug>/edit/', views.ManufacturerEditView.as_view(), name='manufacturer_edit'),
path(r'manufacturers/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='manufacturer_changelog', kwargs={'model': Manufacturer}),
path('manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'),
path('manufacturers/add/', views.ManufacturerCreateView.as_view(), name='manufacturer_add'),
path('manufacturers/import/', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'),
path('manufacturers/delete/', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'),
path('manufacturers/<slug:slug>/edit/', views.ManufacturerEditView.as_view(), name='manufacturer_edit'),
path('manufacturers/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='manufacturer_changelog', kwargs={'model': Manufacturer}),
# Device types
path(r'device-types/', views.DeviceTypeListView.as_view(), name='devicetype_list'),
path(r'device-types/add/', views.DeviceTypeCreateView.as_view(), name='devicetype_add'),
path(r'device-types/import/', views.DeviceTypeImportView.as_view(), name='devicetype_import'),
path(r'device-types/edit/', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'),
path(r'device-types/delete/', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'),
path(r'device-types/<int:pk>/', views.DeviceTypeView.as_view(), name='devicetype'),
path(r'device-types/<int:pk>/edit/', views.DeviceTypeEditView.as_view(), name='devicetype_edit'),
path(r'device-types/<int:pk>/delete/', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'),
path(r'device-types/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}),
path('device-types/', views.DeviceTypeListView.as_view(), name='devicetype_list'),
path('device-types/add/', views.DeviceTypeCreateView.as_view(), name='devicetype_add'),
path('device-types/import/', views.DeviceTypeImportView.as_view(), name='devicetype_import'),
path('device-types/edit/', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'),
path('device-types/delete/', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'),
path('device-types/<int:pk>/', views.DeviceTypeView.as_view(), name='devicetype'),
path('device-types/<int:pk>/edit/', views.DeviceTypeEditView.as_view(), name='devicetype_edit'),
path('device-types/<int:pk>/delete/', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'),
path('device-types/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}),
# Console port templates
path(r'device-types/<int:pk>/console-ports/add/', views.ConsolePortTemplateCreateView.as_view(), name='devicetype_add_consoleport'),
path(r'device-types/<int:pk>/console-ports/delete/', views.ConsolePortTemplateBulkDeleteView.as_view(), name='devicetype_delete_consoleport'),
path(r'console-port-templates/<int:pk>/edit/', views.ConsolePortTemplateEditView.as_view(), name='consoleporttemplate_edit'),
path('console-port-templates/add/', views.ConsolePortTemplateCreateView.as_view(), name='consoleporttemplate_add'),
path('console-port-templates/edit/', views.ConsolePortTemplateBulkEditView.as_view(), name='consoleporttemplate_bulk_edit'),
path('console-port-templates/delete/', views.ConsolePortTemplateBulkDeleteView.as_view(), name='consoleporttemplate_bulk_delete'),
path('console-port-templates/<int:pk>/edit/', views.ConsolePortTemplateEditView.as_view(), name='consoleporttemplate_edit'),
path('console-port-templates/<int:pk>/delete/', views.ConsolePortTemplateDeleteView.as_view(), name='consoleporttemplate_delete'),
# Console server port templates
path(r'device-types/<int:pk>/console-server-ports/add/', views.ConsoleServerPortTemplateCreateView.as_view(), name='devicetype_add_consoleserverport'),
path(r'device-types/<int:pk>/console-server-ports/delete/', views.ConsoleServerPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_consoleserverport'),
path(r'console-server-port-templates/<int:pk>/edit/', views.ConsoleServerPortTemplateEditView.as_view(), name='consoleserverporttemplate_edit'),
path('console-server-port-templates/add/', views.ConsoleServerPortTemplateCreateView.as_view(), name='consoleserverporttemplate_add'),
path('console-server-port-templates/edit/', views.ConsoleServerPortTemplateBulkEditView.as_view(), name='consoleserverporttemplate_bulk_edit'),
path('console-server-port-templates/delete/', views.ConsoleServerPortTemplateBulkDeleteView.as_view(), name='consoleserverporttemplate_bulk_delete'),
path('console-server-port-templates/<int:pk>/edit/', views.ConsoleServerPortTemplateEditView.as_view(), name='consoleserverporttemplate_edit'),
path('console-server-port-templates/<int:pk>/delete/', views.ConsoleServerPortTemplateDeleteView.as_view(), name='consoleserverporttemplate_delete'),
# Power port templates
path(r'device-types/<int:pk>/power-ports/add/', views.PowerPortTemplateCreateView.as_view(), name='devicetype_add_powerport'),
path(r'device-types/<int:pk>/power-ports/delete/', views.PowerPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_powerport'),
path(r'power-port-templates/<int:pk>/edit/', views.PowerPortTemplateEditView.as_view(), name='powerporttemplate_edit'),
path('power-port-templates/add/', views.PowerPortTemplateCreateView.as_view(), name='powerporttemplate_add'),
path('power-port-templates/edit/', views.PowerPortTemplateBulkEditView.as_view(), name='powerporttemplate_bulk_edit'),
path('power-port-templates/delete/', views.PowerPortTemplateBulkDeleteView.as_view(), name='powerporttemplate_bulk_delete'),
path('power-port-templates/<int:pk>/edit/', views.PowerPortTemplateEditView.as_view(), name='powerporttemplate_edit'),
path('power-port-templates/<int:pk>/delete/', views.PowerPortTemplateDeleteView.as_view(), name='powerporttemplate_delete'),
# Power outlet templates
path(r'device-types/<int:pk>/power-outlets/add/', views.PowerOutletTemplateCreateView.as_view(), name='devicetype_add_poweroutlet'),
path(r'device-types/<int:pk>/power-outlets/delete/', views.PowerOutletTemplateBulkDeleteView.as_view(), name='devicetype_delete_poweroutlet'),
path(r'power-outlet-templates/<int:pk>/edit/', views.PowerOutletTemplateEditView.as_view(), name='poweroutlettemplate_edit'),
path('power-outlet-templates/add/', views.PowerOutletTemplateCreateView.as_view(), name='poweroutlettemplate_add'),
path('power-outlet-templates/edit/', views.PowerOutletTemplateBulkEditView.as_view(), name='poweroutlettemplate_bulk_edit'),
path('power-outlet-templates/delete/', views.PowerOutletTemplateBulkDeleteView.as_view(), name='poweroutlettemplate_bulk_delete'),
path('power-outlet-templates/<int:pk>/edit/', views.PowerOutletTemplateEditView.as_view(), name='poweroutlettemplate_edit'),
path('power-outlet-templates/<int:pk>/delete/', views.PowerOutletTemplateDeleteView.as_view(), name='poweroutlettemplate_delete'),
# Interface templates
path(r'device-types/<int:pk>/interfaces/add/', views.InterfaceTemplateCreateView.as_view(), name='devicetype_add_interface'),
path(r'device-types/<int:pk>/interfaces/edit/', views.InterfaceTemplateBulkEditView.as_view(), name='devicetype_bulkedit_interface'),
path(r'device-types/<int:pk>/interfaces/delete/', views.InterfaceTemplateBulkDeleteView.as_view(), name='devicetype_delete_interface'),
path(r'interface-templates/<int:pk>/edit/', views.InterfaceTemplateEditView.as_view(), name='interfacetemplate_edit'),
path('interface-templates/add/', views.InterfaceTemplateCreateView.as_view(), name='interfacetemplate_add'),
path('interface-templates/edit/', views.InterfaceTemplateBulkEditView.as_view(), name='interfacetemplate_bulk_edit'),
path('interface-templates/delete/', views.InterfaceTemplateBulkDeleteView.as_view(), name='interfacetemplate_bulk_delete'),
path('interface-templates/<int:pk>/edit/', views.InterfaceTemplateEditView.as_view(), name='interfacetemplate_edit'),
path('interface-templates/<int:pk>/delete/', views.InterfaceTemplateDeleteView.as_view(), name='interfacetemplate_delete'),
# Front port templates
path(r'device-types/<int:pk>/front-ports/add/', views.FrontPortTemplateCreateView.as_view(), name='devicetype_add_frontport'),
path(r'device-types/<int:pk>/front-ports/delete/', views.FrontPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_frontport'),
path(r'front-port-templates/<int:pk>/edit/', views.FrontPortTemplateEditView.as_view(), name='frontporttemplate_edit'),
path('front-port-templates/add/', views.FrontPortTemplateCreateView.as_view(), name='frontporttemplate_add'),
path('front-port-templates/edit/', views.FrontPortTemplateBulkEditView.as_view(), name='frontporttemplate_bulk_edit'),
path('front-port-templates/delete/', views.FrontPortTemplateBulkDeleteView.as_view(), name='frontporttemplate_bulk_delete'),
path('front-port-templates/<int:pk>/edit/', views.FrontPortTemplateEditView.as_view(), name='frontporttemplate_edit'),
path('front-port-templates/<int:pk>/delete/', views.FrontPortTemplateDeleteView.as_view(), name='frontporttemplate_delete'),
# Rear port templates
path(r'device-types/<int:pk>/rear-ports/add/', views.RearPortTemplateCreateView.as_view(), name='devicetype_add_rearport'),
path(r'device-types/<int:pk>/rear-ports/delete/', views.RearPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_rearport'),
path(r'rear-port-templates/<int:pk>/edit/', views.RearPortTemplateEditView.as_view(), name='rearporttemplate_edit'),
path('rear-port-templates/add/', views.RearPortTemplateCreateView.as_view(), name='rearporttemplate_add'),
path('rear-port-templates/edit/', views.RearPortTemplateBulkEditView.as_view(), name='rearporttemplate_bulk_edit'),
path('rear-port-templates/delete/', views.RearPortTemplateBulkDeleteView.as_view(), name='rearporttemplate_bulk_delete'),
path('rear-port-templates/<int:pk>/edit/', views.RearPortTemplateEditView.as_view(), name='rearporttemplate_edit'),
path('rear-port-templates/<int:pk>/delete/', views.RearPortTemplateDeleteView.as_view(), name='rearporttemplate_delete'),
# Device bay templates
path(r'device-types/<int:pk>/device-bays/add/', views.DeviceBayTemplateCreateView.as_view(), name='devicetype_add_devicebay'),
path(r'device-types/<int:pk>/device-bays/delete/', views.DeviceBayTemplateBulkDeleteView.as_view(), name='devicetype_delete_devicebay'),
path(r'device-bay-templates/<int:pk>/edit/', views.DeviceBayTemplateEditView.as_view(), name='devicebaytemplate_edit'),
path('device-bay-templates/add/', views.DeviceBayTemplateCreateView.as_view(), name='devicebaytemplate_add'),
# path('device-bay-templates/edit/', views.DeviceBayTemplateBulkEditView.as_view(), name='devicebaytemplate_bulk_edit'),
path('device-bay-templates/delete/', views.DeviceBayTemplateBulkDeleteView.as_view(), name='devicebaytemplate_bulk_delete'),
path('device-bay-templates/<int:pk>/edit/', views.DeviceBayTemplateEditView.as_view(), name='devicebaytemplate_edit'),
path('device-bay-templates/<int:pk>/delete/', views.DeviceBayTemplateDeleteView.as_view(), name='devicebaytemplate_delete'),
# Device roles
path(r'device-roles/', views.DeviceRoleListView.as_view(), name='devicerole_list'),
path(r'device-roles/add/', views.DeviceRoleCreateView.as_view(), name='devicerole_add'),
path(r'device-roles/import/', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'),
path(r'device-roles/delete/', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'),
path(r'device-roles/<slug:slug>/edit/', views.DeviceRoleEditView.as_view(), name='devicerole_edit'),
path(r'device-roles/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='devicerole_changelog', kwargs={'model': DeviceRole}),
path('device-roles/', views.DeviceRoleListView.as_view(), name='devicerole_list'),
path('device-roles/add/', views.DeviceRoleCreateView.as_view(), name='devicerole_add'),
path('device-roles/import/', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'),
path('device-roles/delete/', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'),
path('device-roles/<slug:slug>/edit/', views.DeviceRoleEditView.as_view(), name='devicerole_edit'),
path('device-roles/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='devicerole_changelog', kwargs={'model': DeviceRole}),
# Platforms
path(r'platforms/', views.PlatformListView.as_view(), name='platform_list'),
path(r'platforms/add/', views.PlatformCreateView.as_view(), name='platform_add'),
path(r'platforms/import/', views.PlatformBulkImportView.as_view(), name='platform_import'),
path(r'platforms/delete/', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'),
path(r'platforms/<slug:slug>/edit/', views.PlatformEditView.as_view(), name='platform_edit'),
path(r'platforms/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='platform_changelog', kwargs={'model': Platform}),
path('platforms/', views.PlatformListView.as_view(), name='platform_list'),
path('platforms/add/', views.PlatformCreateView.as_view(), name='platform_add'),
path('platforms/import/', views.PlatformBulkImportView.as_view(), name='platform_import'),
path('platforms/delete/', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'),
path('platforms/<slug:slug>/edit/', views.PlatformEditView.as_view(), name='platform_edit'),
path('platforms/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='platform_changelog', kwargs={'model': Platform}),
# Devices
path(r'devices/', views.DeviceListView.as_view(), name='device_list'),
path(r'devices/add/', views.DeviceCreateView.as_view(), name='device_add'),
path(r'devices/import/', views.DeviceBulkImportView.as_view(), name='device_import'),
path(r'devices/import/child-devices/', views.ChildDeviceBulkImportView.as_view(), name='device_import_child'),
path(r'devices/edit/', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'),
path(r'devices/delete/', views.DeviceBulkDeleteView.as_view(), name='device_bulk_delete'),
path(r'devices/<int:pk>/', views.DeviceView.as_view(), name='device'),
path(r'devices/<int:pk>/edit/', views.DeviceEditView.as_view(), name='device_edit'),
path(r'devices/<int:pk>/delete/', views.DeviceDeleteView.as_view(), name='device_delete'),
path(r'devices/<int:pk>/config-context/', views.DeviceConfigContextView.as_view(), name='device_configcontext'),
path(r'devices/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}),
path(r'devices/<int:pk>/inventory/', views.DeviceInventoryView.as_view(), name='device_inventory'),
path(r'devices/<int:pk>/status/', views.DeviceStatusView.as_view(), name='device_status'),
path(r'devices/<int:pk>/lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
path(r'devices/<int:pk>/config/', views.DeviceConfigView.as_view(), name='device_config'),
path(r'devices/<int:pk>/add-secret/', secret_add, name='device_addsecret'),
path(r'devices/<int:device>/services/assign/', ServiceCreateView.as_view(), name='device_service_assign'),
path(r'devices/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}),
path('devices/', views.DeviceListView.as_view(), name='device_list'),
path('devices/add/', views.DeviceCreateView.as_view(), name='device_add'),
path('devices/import/', views.DeviceBulkImportView.as_view(), name='device_import'),
path('devices/import/child-devices/', views.ChildDeviceBulkImportView.as_view(), name='device_import_child'),
path('devices/edit/', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'),
path('devices/delete/', views.DeviceBulkDeleteView.as_view(), name='device_bulk_delete'),
path('devices/<int:pk>/', views.DeviceView.as_view(), name='device'),
path('devices/<int:pk>/edit/', views.DeviceEditView.as_view(), name='device_edit'),
path('devices/<int:pk>/delete/', views.DeviceDeleteView.as_view(), name='device_delete'),
path('devices/<int:pk>/config-context/', views.DeviceConfigContextView.as_view(), name='device_configcontext'),
path('devices/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}),
path('devices/<int:pk>/inventory/', views.DeviceInventoryView.as_view(), name='device_inventory'),
path('devices/<int:pk>/status/', views.DeviceStatusView.as_view(), name='device_status'),
path('devices/<int:pk>/lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
path('devices/<int:pk>/config/', views.DeviceConfigView.as_view(), name='device_config'),
path('devices/<int:pk>/add-secret/', secret_add, name='device_addsecret'),
path('devices/<int:device>/services/assign/', ServiceCreateView.as_view(), name='device_service_assign'),
path('devices/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}),
# Console ports
path(r'devices/console-ports/add/', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),
path(r'devices/<int:pk>/console-ports/add/', views.ConsolePortCreateView.as_view(), name='consoleport_add'),
path(r'devices/<int:pk>/console-ports/delete/', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'),
path(r'console-ports/', views.ConsolePortListView.as_view(), name='consoleport_list'),
path(r'console-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}),
path(r'console-ports/<int:pk>/edit/', views.ConsolePortEditView.as_view(), name='consoleport_edit'),
path(r'console-ports/<int:pk>/delete/', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'),
path(r'console-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}),
path(r'console-ports/import/', views.ConsolePortBulkImportView.as_view(), name='consoleport_import'),
path('console-ports/', views.ConsolePortListView.as_view(), name='consoleport_list'),
path('console-ports/add/', views.ConsolePortCreateView.as_view(), name='consoleport_add'),
path('console-ports/import/', views.ConsolePortBulkImportView.as_view(), name='consoleport_import'),
path('console-ports/edit/', views.ConsolePortBulkEditView.as_view(), name='consoleport_bulk_edit'),
# TODO: Bulk rename, disconnect views for ConsolePorts
path('console-ports/delete/', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'),
path('console-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}),
path('console-ports/<int:pk>/edit/', views.ConsolePortEditView.as_view(), name='consoleport_edit'),
path('console-ports/<int:pk>/delete/', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'),
path('console-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}),
path('devices/console-ports/add/', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),
# Console server ports
path(r'devices/console-server-ports/add/', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'),
path(r'devices/<int:pk>/console-server-ports/add/', views.ConsoleServerPortCreateView.as_view(), name='consoleserverport_add'),
path(r'devices/<int:pk>/console-server-ports/edit/', views.ConsoleServerPortBulkEditView.as_view(), name='consoleserverport_bulk_edit'),
path(r'devices/<int:pk>/console-server-ports/delete/', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'),
path(r'console-server-ports/', views.ConsoleServerPortListView.as_view(), name='consoleserverport_list'),
path(r'console-server-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}),
path(r'console-server-ports/<int:pk>/edit/', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'),
path(r'console-server-ports/<int:pk>/delete/', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'),
path(r'console-server-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}),
path(r'console-server-ports/rename/', views.ConsoleServerPortBulkRenameView.as_view(), name='consoleserverport_bulk_rename'),
path(r'console-server-ports/disconnect/', views.ConsoleServerPortBulkDisconnectView.as_view(), name='consoleserverport_bulk_disconnect'),
path(r'console-server-ports/import/', views.ConsoleServerPortBulkImportView.as_view(), name='consoleserverport_import'),
path('console-server-ports/', views.ConsoleServerPortListView.as_view(), name='consoleserverport_list'),
path('console-server-ports/add/', views.ConsoleServerPortCreateView.as_view(), name='consoleserverport_add'),
path('console-server-ports/import/', views.ConsoleServerPortBulkImportView.as_view(), name='consoleserverport_import'),
path('console-server-ports/edit/', views.ConsoleServerPortBulkEditView.as_view(), name='consoleserverport_bulk_edit'),
path('console-server-ports/rename/', views.ConsoleServerPortBulkRenameView.as_view(), name='consoleserverport_bulk_rename'),
path('console-server-ports/disconnect/', views.ConsoleServerPortBulkDisconnectView.as_view(), name='consoleserverport_bulk_disconnect'),
path('console-server-ports/delete/', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'),
path('console-server-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}),
path('console-server-ports/<int:pk>/edit/', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'),
path('console-server-ports/<int:pk>/delete/', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'),
path('console-server-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}),
path('devices/console-server-ports/add/', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'),
# Power ports
path(r'devices/power-ports/add/', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'),
path(r'devices/<int:pk>/power-ports/add/', views.PowerPortCreateView.as_view(), name='powerport_add'),
path(r'devices/<int:pk>/power-ports/delete/', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'),
path(r'power-ports/', views.PowerPortListView.as_view(), name='powerport_list'),
path(r'power-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}),
path(r'power-ports/<int:pk>/edit/', views.PowerPortEditView.as_view(), name='powerport_edit'),
path(r'power-ports/<int:pk>/delete/', views.PowerPortDeleteView.as_view(), name='powerport_delete'),
path(r'power-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}),
path(r'power-ports/import/', views.PowerPortBulkImportView.as_view(), name='powerport_import'),
path('power-ports/', views.PowerPortListView.as_view(), name='powerport_list'),
path('power-ports/add/', views.PowerPortCreateView.as_view(), name='powerport_add'),
path('power-ports/import/', views.PowerPortBulkImportView.as_view(), name='powerport_import'),
path('power-ports/edit/', views.PowerPortBulkEditView.as_view(), name='powerport_bulk_edit'),
# TODO: Bulk rename, disconnect views for PowerPorts
path('power-ports/delete/', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'),
path('power-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}),
path('power-ports/<int:pk>/edit/', views.PowerPortEditView.as_view(), name='powerport_edit'),
path('power-ports/<int:pk>/delete/', views.PowerPortDeleteView.as_view(), name='powerport_delete'),
path('power-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}),
path('devices/power-ports/add/', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'),
# Power outlets
path(r'devices/power-outlets/add/', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'),
path(r'devices/<int:pk>/power-outlets/add/', views.PowerOutletCreateView.as_view(), name='poweroutlet_add'),
path(r'devices/<int:pk>/power-outlets/edit/', views.PowerOutletBulkEditView.as_view(), name='poweroutlet_bulk_edit'),
path(r'devices/<int:pk>/power-outlets/delete/', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'),
path(r'power-outlets/', views.PowerOutletListView.as_view(), name='poweroutlet_list'),
path(r'power-outlets/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}),
path(r'power-outlets/<int:pk>/edit/', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'),
path(r'power-outlets/<int:pk>/delete/', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'),
path(r'power-outlets/<int:pk>/trace/', views.CableTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}),
path(r'power-outlets/rename/', views.PowerOutletBulkRenameView.as_view(), name='poweroutlet_bulk_rename'),
path(r'power-outlets/disconnect/', views.PowerOutletBulkDisconnectView.as_view(), name='poweroutlet_bulk_disconnect'),
path(r'power-outlets/import/', views.PowerOutletBulkImportView.as_view(), name='poweroutlet_import'),
path('power-outlets/', views.PowerOutletListView.as_view(), name='poweroutlet_list'),
path('power-outlets/add/', views.PowerOutletCreateView.as_view(), name='poweroutlet_add'),
path('power-outlets/import/', views.PowerOutletBulkImportView.as_view(), name='poweroutlet_import'),
path('power-outlets/edit/', views.PowerOutletBulkEditView.as_view(), name='poweroutlet_bulk_edit'),
path('power-outlets/rename/', views.PowerOutletBulkRenameView.as_view(), name='poweroutlet_bulk_rename'),
path('power-outlets/disconnect/', views.PowerOutletBulkDisconnectView.as_view(), name='poweroutlet_bulk_disconnect'),
path('power-outlets/delete/', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'),
path('power-outlets/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}),
path('power-outlets/<int:pk>/edit/', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'),
path('power-outlets/<int:pk>/delete/', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'),
path('power-outlets/<int:pk>/trace/', views.CableTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}),
path('devices/power-outlets/add/', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'),
# Interfaces
path(r'devices/interfaces/add/', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'),
path(r'devices/<int:pk>/interfaces/add/', views.InterfaceCreateView.as_view(), name='interface_add'),
path(r'devices/<int:pk>/interfaces/edit/', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'),
path(r'devices/<int:pk>/interfaces/delete/', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
path(r'interfaces/', views.InterfaceListView.as_view(), name='interface_list'),
path(r'interfaces/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}),
path(r'interfaces/<int:pk>/', views.InterfaceView.as_view(), name='interface'),
path(r'interfaces/<int:pk>/edit/', views.InterfaceEditView.as_view(), name='interface_edit'),
path(r'interfaces/<int:pk>/delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'),
path(r'interfaces/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}),
path(r'interfaces/<int:pk>/trace/', views.CableTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}),
path(r'interfaces/rename/', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'),
path(r'interfaces/disconnect/', views.InterfaceBulkDisconnectView.as_view(), name='interface_bulk_disconnect'),
path(r'interfaces/import/', views.InterfaceBulkImportView.as_view(), name='interface_import'),
path('interfaces/', views.InterfaceListView.as_view(), name='interface_list'),
path('interfaces/add/', views.InterfaceCreateView.as_view(), name='interface_add'),
path('interfaces/import/', views.InterfaceBulkImportView.as_view(), name='interface_import'),
path('interfaces/edit/', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'),
path('interfaces/rename/', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'),
path('interfaces/disconnect/', views.InterfaceBulkDisconnectView.as_view(), name='interface_bulk_disconnect'),
path('interfaces/delete/', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
path('interfaces/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}),
path('interfaces/<int:pk>/', views.InterfaceView.as_view(), name='interface'),
path('interfaces/<int:pk>/edit/', views.InterfaceEditView.as_view(), name='interface_edit'),
path('interfaces/<int:pk>/delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'),
path('interfaces/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}),
path('interfaces/<int:pk>/trace/', views.CableTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}),
path('devices/interfaces/add/', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'),
# Front ports
# path(r'devices/front-ports/add/', views.DeviceBulkAddFrontPortView.as_view(), name='device_bulk_add_frontport'),
path(r'devices/<int:pk>/front-ports/add/', views.FrontPortCreateView.as_view(), name='frontport_add'),
path(r'devices/<int:pk>/front-ports/edit/', views.FrontPortBulkEditView.as_view(), name='frontport_bulk_edit'),
path(r'devices/<int:pk>/front-ports/delete/', views.FrontPortBulkDeleteView.as_view(), name='frontport_bulk_delete'),
path(r'front-ports/', views.FrontPortListView.as_view(), name='frontport_list'),
path(r'front-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}),
path(r'front-ports/<int:pk>/edit/', views.FrontPortEditView.as_view(), name='frontport_edit'),
path(r'front-ports/<int:pk>/delete/', views.FrontPortDeleteView.as_view(), name='frontport_delete'),
path(r'front-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}),
path(r'front-ports/rename/', views.FrontPortBulkRenameView.as_view(), name='frontport_bulk_rename'),
path(r'front-ports/disconnect/', views.FrontPortBulkDisconnectView.as_view(), name='frontport_bulk_disconnect'),
path(r'front-ports/import/', views.FrontPortBulkImportView.as_view(), name='frontport_import'),
path('front-ports/', views.FrontPortListView.as_view(), name='frontport_list'),
path('front-ports/add/', views.FrontPortCreateView.as_view(), name='frontport_add'),
path('front-ports/import/', views.FrontPortBulkImportView.as_view(), name='frontport_import'),
path('front-ports/edit/', views.FrontPortBulkEditView.as_view(), name='frontport_bulk_edit'),
path('front-ports/rename/', views.FrontPortBulkRenameView.as_view(), name='frontport_bulk_rename'),
path('front-ports/disconnect/', views.FrontPortBulkDisconnectView.as_view(), name='frontport_bulk_disconnect'),
path('front-ports/delete/', views.FrontPortBulkDeleteView.as_view(), name='frontport_bulk_delete'),
path('front-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}),
path('front-ports/<int:pk>/edit/', views.FrontPortEditView.as_view(), name='frontport_edit'),
path('front-ports/<int:pk>/delete/', views.FrontPortDeleteView.as_view(), name='frontport_delete'),
path('front-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}),
# path('devices/front-ports/add/', views.DeviceBulkAddFrontPortView.as_view(), name='device_bulk_add_frontport'),
# Rear ports
# path(r'devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'),
path(r'devices/<int:pk>/rear-ports/add/', views.RearPortCreateView.as_view(), name='rearport_add'),
path(r'devices/<int:pk>/rear-ports/edit/', views.RearPortBulkEditView.as_view(), name='rearport_bulk_edit'),
path(r'devices/<int:pk>/rear-ports/delete/', views.RearPortBulkDeleteView.as_view(), name='rearport_bulk_delete'),
path(r'rear-ports/', views.RearPortListView.as_view(), name='rearport_list'),
path(r'rear-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}),
path(r'rear-ports/<int:pk>/edit/', views.RearPortEditView.as_view(), name='rearport_edit'),
path(r'rear-ports/<int:pk>/delete/', views.RearPortDeleteView.as_view(), name='rearport_delete'),
path(r'rear-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}),
path(r'rear-ports/rename/', views.RearPortBulkRenameView.as_view(), name='rearport_bulk_rename'),
path(r'rear-ports/disconnect/', views.RearPortBulkDisconnectView.as_view(), name='rearport_bulk_disconnect'),
path(r'rear-ports/import/', views.RearPortBulkImportView.as_view(), name='rearport_import'),
path('rear-ports/', views.RearPortListView.as_view(), name='rearport_list'),
path('rear-ports/add/', views.RearPortCreateView.as_view(), name='rearport_add'),
path('rear-ports/import/', views.RearPortBulkImportView.as_view(), name='rearport_import'),
path('rear-ports/edit/', views.RearPortBulkEditView.as_view(), name='rearport_bulk_edit'),
path('rear-ports/rename/', views.RearPortBulkRenameView.as_view(), name='rearport_bulk_rename'),
path('rear-ports/disconnect/', views.RearPortBulkDisconnectView.as_view(), name='rearport_bulk_disconnect'),
path('rear-ports/delete/', views.RearPortBulkDeleteView.as_view(), name='rearport_bulk_delete'),
path('rear-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}),
path('rear-ports/<int:pk>/edit/', views.RearPortEditView.as_view(), name='rearport_edit'),
path('rear-ports/<int:pk>/delete/', views.RearPortDeleteView.as_view(), name='rearport_delete'),
path('rear-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}),
# path('devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'),
# Device bays
path(r'devices/device-bays/add/', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'),
path(r'devices/<int:pk>/bays/add/', views.DeviceBayCreateView.as_view(), name='devicebay_add'),
path(r'devices/<int:pk>/bays/delete/', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'),
path(r'device-bays/', views.DeviceBayListView.as_view(), name='devicebay_list'),
path(r'device-bays/<int:pk>/edit/', views.DeviceBayEditView.as_view(), name='devicebay_edit'),
path(r'device-bays/<int:pk>/delete/', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'),
path(r'device-bays/<int:pk>/populate/', views.DeviceBayPopulateView.as_view(), name='devicebay_populate'),
path(r'device-bays/<int:pk>/depopulate/', views.DeviceBayDepopulateView.as_view(), name='devicebay_depopulate'),
path(r'device-bays/rename/', views.DeviceBayBulkRenameView.as_view(), name='devicebay_bulk_rename'),
path(r'device-bays/import/', views.DeviceBayBulkImportView.as_view(), name='devicebay_import'),
path('device-bays/', views.DeviceBayListView.as_view(), name='devicebay_list'),
path('device-bays/add/', views.DeviceBayCreateView.as_view(), name='devicebay_add'),
path('device-bays/import/', views.DeviceBayBulkImportView.as_view(), name='devicebay_import'),
# TODO: Bulk edit view for DeviceBays
path('device-bays/rename/', views.DeviceBayBulkRenameView.as_view(), name='devicebay_bulk_rename'),
path('device-bays/delete/', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'),
path('device-bays/<int:pk>/edit/', views.DeviceBayEditView.as_view(), name='devicebay_edit'),
path('device-bays/<int:pk>/delete/', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'),
path('device-bays/<int:pk>/populate/', views.DeviceBayPopulateView.as_view(), name='devicebay_populate'),
path('device-bays/<int:pk>/depopulate/', views.DeviceBayDepopulateView.as_view(), name='devicebay_depopulate'),
path('devices/device-bays/add/', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'),
# Inventory items
path(r'inventory-items/', views.InventoryItemListView.as_view(), name='inventoryitem_list'),
path(r'inventory-items/import/', views.InventoryItemBulkImportView.as_view(), name='inventoryitem_import'),
path(r'inventory-items/edit/', views.InventoryItemBulkEditView.as_view(), name='inventoryitem_bulk_edit'),
path(r'inventory-items/delete/', views.InventoryItemBulkDeleteView.as_view(), name='inventoryitem_bulk_delete'),
path(r'inventory-items/<int:pk>/edit/', views.InventoryItemEditView.as_view(), name='inventoryitem_edit'),
path(r'inventory-items/<int:pk>/delete/', views.InventoryItemDeleteView.as_view(), name='inventoryitem_delete'),
path(r'devices/<int:device>/inventory-items/add/', views.InventoryItemEditView.as_view(), name='inventoryitem_add'),
path('inventory-items/', views.InventoryItemListView.as_view(), name='inventoryitem_list'),
path('inventory-items/add/', views.InventoryItemCreateView.as_view(), name='inventoryitem_add'),
path('inventory-items/import/', views.InventoryItemBulkImportView.as_view(), name='inventoryitem_import'),
path('inventory-items/edit/', views.InventoryItemBulkEditView.as_view(), name='inventoryitem_bulk_edit'),
# TODO: Bulk rename view for InventoryItems
path('inventory-items/delete/', views.InventoryItemBulkDeleteView.as_view(), name='inventoryitem_bulk_delete'),
path('inventory-items/<int:pk>/edit/', views.InventoryItemEditView.as_view(), name='inventoryitem_edit'),
path('inventory-items/<int:pk>/delete/', views.InventoryItemDeleteView.as_view(), name='inventoryitem_delete'),
# Cables
path(r'cables/', views.CableListView.as_view(), name='cable_list'),
path(r'cables/import/', views.CableBulkImportView.as_view(), name='cable_import'),
path(r'cables/edit/', views.CableBulkEditView.as_view(), name='cable_bulk_edit'),
path(r'cables/delete/', views.CableBulkDeleteView.as_view(), name='cable_bulk_delete'),
path(r'cables/<int:pk>/', views.CableView.as_view(), name='cable'),
path(r'cables/<int:pk>/edit/', views.CableEditView.as_view(), name='cable_edit'),
path(r'cables/<int:pk>/delete/', views.CableDeleteView.as_view(), name='cable_delete'),
path(r'cables/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='cable_changelog', kwargs={'model': Cable}),
path('cables/', views.CableListView.as_view(), name='cable_list'),
path('cables/import/', views.CableBulkImportView.as_view(), name='cable_import'),
path('cables/edit/', views.CableBulkEditView.as_view(), name='cable_bulk_edit'),
path('cables/delete/', views.CableBulkDeleteView.as_view(), name='cable_bulk_delete'),
path('cables/<int:pk>/', views.CableView.as_view(), name='cable'),
path('cables/<int:pk>/edit/', views.CableEditView.as_view(), name='cable_edit'),
path('cables/<int:pk>/delete/', views.CableDeleteView.as_view(), name='cable_delete'),
path('cables/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='cable_changelog', kwargs={'model': Cable}),
# Console/power/interface connections (read-only)
path(r'console-connections/', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'),
path(r'power-connections/', views.PowerConnectionsListView.as_view(), name='power_connections_list'),
path(r'interface-connections/', views.InterfaceConnectionsListView.as_view(), name='interface_connections_list'),
path('console-connections/', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'),
path('power-connections/', views.PowerConnectionsListView.as_view(), name='power_connections_list'),
path('interface-connections/', views.InterfaceConnectionsListView.as_view(), name='interface_connections_list'),
# Virtual chassis
path(r'virtual-chassis/', views.VirtualChassisListView.as_view(), name='virtualchassis_list'),
path(r'virtual-chassis/add/', views.VirtualChassisCreateView.as_view(), name='virtualchassis_add'),
path(r'virtual-chassis/<int:pk>/edit/', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'),
path(r'virtual-chassis/<int:pk>/delete/', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'),
path(r'virtual-chassis/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='virtualchassis_changelog', kwargs={'model': VirtualChassis}),
path(r'virtual-chassis/<int:pk>/add-member/', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'),
path(r'virtual-chassis-members/<int:pk>/delete/', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'),
path('virtual-chassis/', views.VirtualChassisListView.as_view(), name='virtualchassis_list'),
path('virtual-chassis/add/', views.VirtualChassisCreateView.as_view(), name='virtualchassis_add'),
path('virtual-chassis/<int:pk>/edit/', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'),
path('virtual-chassis/<int:pk>/delete/', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'),
path('virtual-chassis/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='virtualchassis_changelog', kwargs={'model': VirtualChassis}),
path('virtual-chassis/<int:pk>/add-member/', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'),
path('virtual-chassis-members/<int:pk>/delete/', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'),
# Power panels
path(r'power-panels/', views.PowerPanelListView.as_view(), name='powerpanel_list'),
path(r'power-panels/add/', views.PowerPanelCreateView.as_view(), name='powerpanel_add'),
path(r'power-panels/import/', views.PowerPanelBulkImportView.as_view(), name='powerpanel_import'),
path(r'power-panels/delete/', views.PowerPanelBulkDeleteView.as_view(), name='powerpanel_bulk_delete'),
path(r'power-panels/<int:pk>/', views.PowerPanelView.as_view(), name='powerpanel'),
path(r'power-panels/<int:pk>/edit/', views.PowerPanelEditView.as_view(), name='powerpanel_edit'),
path(r'power-panels/<int:pk>/delete/', views.PowerPanelDeleteView.as_view(), name='powerpanel_delete'),
path(r'power-panels/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerpanel_changelog', kwargs={'model': PowerPanel}),
path('power-panels/', views.PowerPanelListView.as_view(), name='powerpanel_list'),
path('power-panels/add/', views.PowerPanelCreateView.as_view(), name='powerpanel_add'),
path('power-panels/import/', views.PowerPanelBulkImportView.as_view(), name='powerpanel_import'),
path('power-panels/delete/', views.PowerPanelBulkDeleteView.as_view(), name='powerpanel_bulk_delete'),
path('power-panels/<int:pk>/', views.PowerPanelView.as_view(), name='powerpanel'),
path('power-panels/<int:pk>/edit/', views.PowerPanelEditView.as_view(), name='powerpanel_edit'),
path('power-panels/<int:pk>/delete/', views.PowerPanelDeleteView.as_view(), name='powerpanel_delete'),
path('power-panels/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerpanel_changelog', kwargs={'model': PowerPanel}),
# Power feeds
path(r'power-feeds/', views.PowerFeedListView.as_view(), name='powerfeed_list'),
path(r'power-feeds/add/', views.PowerFeedCreateView.as_view(), name='powerfeed_add'),
path(r'power-feeds/import/', views.PowerFeedBulkImportView.as_view(), name='powerfeed_import'),
path(r'power-feeds/edit/', views.PowerFeedBulkEditView.as_view(), name='powerfeed_bulk_edit'),
path(r'power-feeds/delete/', views.PowerFeedBulkDeleteView.as_view(), name='powerfeed_bulk_delete'),
path(r'power-feeds/<int:pk>/', views.PowerFeedView.as_view(), name='powerfeed'),
path(r'power-feeds/<int:pk>/edit/', views.PowerFeedEditView.as_view(), name='powerfeed_edit'),
path(r'power-feeds/<int:pk>/delete/', views.PowerFeedDeleteView.as_view(), name='powerfeed_delete'),
path(r'power-feeds/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerfeed_changelog', kwargs={'model': PowerFeed}),
path('power-feeds/', views.PowerFeedListView.as_view(), name='powerfeed_list'),
path('power-feeds/add/', views.PowerFeedCreateView.as_view(), name='powerfeed_add'),
path('power-feeds/import/', views.PowerFeedBulkImportView.as_view(), name='powerfeed_import'),
path('power-feeds/edit/', views.PowerFeedBulkEditView.as_view(), name='powerfeed_bulk_edit'),
path('power-feeds/delete/', views.PowerFeedBulkDeleteView.as_view(), name='powerfeed_bulk_delete'),
path('power-feeds/<int:pk>/', views.PowerFeedView.as_view(), name='powerfeed'),
path('power-feeds/<int:pk>/edit/', views.PowerFeedEditView.as_view(), name='powerfeed_edit'),
path('power-feeds/<int:pk>/delete/', views.PowerFeedDeleteView.as_view(), name='powerfeed_delete'),
path('power-feeds/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerfeed_changelog', kwargs={'model': PowerFeed}),
]

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):
@@ -700,13 +694,11 @@ class DeviceTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
#
# Device type components
# Console port templates
#
class ConsolePortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_consoleporttemplate'
parent_model = DeviceType
parent_field = 'device_type'
model = ConsolePortTemplate
form = forms.ConsolePortTemplateCreateForm
model_form = forms.ConsolePortTemplateForm
@@ -719,17 +711,30 @@ class ConsolePortTemplateEditView(PermissionRequiredMixin, ObjectEditView):
model_form = forms.ConsolePortTemplateForm
class ConsolePortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_consoleporttemplate'
model = ConsolePortTemplate
class ConsolePortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_consoleporttemplate'
queryset = ConsolePortTemplate.objects.all()
table = tables.ConsolePortTemplateTable
form = forms.ConsolePortTemplateBulkEditForm
class ConsolePortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_consoleporttemplate'
queryset = ConsolePortTemplate.objects.all()
parent_model = DeviceType
table = tables.ConsolePortTemplateTable
#
# Console server port templates
#
class ConsoleServerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_consoleserverporttemplate'
parent_model = DeviceType
parent_field = 'device_type'
model = ConsoleServerPortTemplate
form = forms.ConsoleServerPortTemplateCreateForm
model_form = forms.ConsoleServerPortTemplateForm
@@ -742,17 +747,30 @@ class ConsoleServerPortTemplateEditView(PermissionRequiredMixin, ObjectEditView)
model_form = forms.ConsoleServerPortTemplateForm
class ConsoleServerPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_consoleserverporttemplate'
model = ConsoleServerPortTemplate
class ConsoleServerPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_consoleserverporttemplate'
queryset = ConsoleServerPortTemplate.objects.all()
table = tables.ConsoleServerPortTemplateTable
form = forms.ConsoleServerPortTemplateBulkEditForm
class ConsoleServerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_consoleserverporttemplate'
queryset = ConsoleServerPortTemplate.objects.all()
parent_model = DeviceType
table = tables.ConsoleServerPortTemplateTable
#
# Power port templates
#
class PowerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_powerporttemplate'
parent_model = DeviceType
parent_field = 'device_type'
model = PowerPortTemplate
form = forms.PowerPortTemplateCreateForm
model_form = forms.PowerPortTemplateForm
@@ -765,17 +783,30 @@ class PowerPortTemplateEditView(PermissionRequiredMixin, ObjectEditView):
model_form = forms.PowerPortTemplateForm
class PowerPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_powerporttemplate'
model = PowerPortTemplate
class PowerPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_powerporttemplate'
queryset = PowerPortTemplate.objects.all()
table = tables.PowerPortTemplateTable
form = forms.PowerPortTemplateBulkEditForm
class PowerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_powerporttemplate'
queryset = PowerPortTemplate.objects.all()
parent_model = DeviceType
table = tables.PowerPortTemplateTable
#
# Power outlet templates
#
class PowerOutletTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_poweroutlettemplate'
parent_model = DeviceType
parent_field = 'device_type'
model = PowerOutletTemplate
form = forms.PowerOutletTemplateCreateForm
model_form = forms.PowerOutletTemplateForm
@@ -788,17 +819,30 @@ class PowerOutletTemplateEditView(PermissionRequiredMixin, ObjectEditView):
model_form = forms.PowerOutletTemplateForm
class PowerOutletTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_poweroutlettemplate'
model = PowerOutletTemplate
class PowerOutletTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_poweroutlettemplate'
queryset = PowerOutletTemplate.objects.all()
table = tables.PowerOutletTemplateTable
form = forms.PowerOutletTemplateBulkEditForm
class PowerOutletTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_poweroutlettemplate'
queryset = PowerOutletTemplate.objects.all()
parent_model = DeviceType
table = tables.PowerOutletTemplateTable
#
# Interface templates
#
class InterfaceTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_interfacetemplate'
parent_model = DeviceType
parent_field = 'device_type'
model = InterfaceTemplate
form = forms.InterfaceTemplateCreateForm
model_form = forms.InterfaceTemplateForm
@@ -811,10 +855,14 @@ class InterfaceTemplateEditView(PermissionRequiredMixin, ObjectEditView):
model_form = forms.InterfaceTemplateForm
class InterfaceTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_interfacetemplate'
model = InterfaceTemplate
class InterfaceTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_interfacetemplate'
queryset = InterfaceTemplate.objects.all()
parent_model = DeviceType
table = tables.InterfaceTemplateTable
form = forms.InterfaceTemplateBulkEditForm
@@ -822,14 +870,15 @@ class InterfaceTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
class InterfaceTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_interfacetemplate'
queryset = InterfaceTemplate.objects.all()
parent_model = DeviceType
table = tables.InterfaceTemplateTable
#
# Front port templates
#
class FrontPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_frontporttemplate'
parent_model = DeviceType
parent_field = 'device_type'
model = FrontPortTemplate
form = forms.FrontPortTemplateCreateForm
model_form = forms.FrontPortTemplateForm
@@ -842,17 +891,30 @@ class FrontPortTemplateEditView(PermissionRequiredMixin, ObjectEditView):
model_form = forms.FrontPortTemplateForm
class FrontPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_frontporttemplate'
model = FrontPortTemplate
class FrontPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_frontporttemplate'
queryset = FrontPortTemplate.objects.all()
table = tables.FrontPortTemplateTable
form = forms.FrontPortTemplateBulkEditForm
class FrontPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_frontporttemplate'
queryset = FrontPortTemplate.objects.all()
parent_model = DeviceType
table = tables.FrontPortTemplateTable
#
# Rear port templates
#
class RearPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_rearporttemplate'
parent_model = DeviceType
parent_field = 'device_type'
model = RearPortTemplate
form = forms.RearPortTemplateCreateForm
model_form = forms.RearPortTemplateForm
@@ -865,17 +927,30 @@ class RearPortTemplateEditView(PermissionRequiredMixin, ObjectEditView):
model_form = forms.RearPortTemplateForm
class RearPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_rearporttemplate'
model = RearPortTemplate
class RearPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_rearporttemplate'
queryset = RearPortTemplate.objects.all()
table = tables.RearPortTemplateTable
form = forms.RearPortTemplateBulkEditForm
class RearPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_rearporttemplate'
queryset = RearPortTemplate.objects.all()
parent_model = DeviceType
table = tables.RearPortTemplateTable
#
# Device bay templates
#
class DeviceBayTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_devicebaytemplate'
parent_model = DeviceType
parent_field = 'device_type'
model = DeviceBayTemplate
form = forms.DeviceBayTemplateCreateForm
model_form = forms.DeviceBayTemplateForm
@@ -888,10 +963,21 @@ class DeviceBayTemplateEditView(PermissionRequiredMixin, ObjectEditView):
model_form = forms.DeviceBayTemplateForm
class DeviceBayTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_devicebaytemplate'
model = DeviceBayTemplate
# class DeviceBayTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
# permission_required = 'dcim.change_devicebaytemplate'
# queryset = DeviceBayTemplate.objects.all()
# table = tables.DeviceBayTemplateTable
# form = forms.DeviceBayTemplateBulkEditForm
class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_devicebaytemplate'
queryset = DeviceBayTemplate.objects.all()
parent_model = DeviceType
table = tables.DeviceBayTemplateTable
@@ -903,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):
@@ -939,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):
@@ -1098,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'
)
@@ -1200,13 +1284,11 @@ class ConsolePortListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.ConsolePortFilterSet
filterset_form = forms.ConsolePortFilterForm
table = tables.ConsolePortDetailTable
template_name = 'dcim/device_component_list.html'
action_buttons = ('import', 'export')
class ConsolePortCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_consoleport'
parent_model = Device
parent_field = 'device'
model = ConsolePort
form = forms.ConsolePortCreateForm
model_form = forms.ConsolePortForm
@@ -1231,11 +1313,18 @@ class ConsolePortBulkImportView(PermissionRequiredMixin, BulkImportView):
default_return_url = 'dcim:consoleport_list'
class ConsolePortBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_consoleport'
queryset = ConsolePort.objects.all()
table = tables.ConsolePortTable
form = forms.ConsolePortBulkEditForm
class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_consoleport'
queryset = ConsolePort.objects.all()
parent_model = Device
table = tables.ConsolePortTable
default_return_url = 'dcim:consoleport_list'
#
@@ -1248,13 +1337,11 @@ class ConsoleServerPortListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.ConsoleServerPortFilterSet
filterset_form = forms.ConsoleServerPortFilterForm
table = tables.ConsoleServerPortDetailTable
template_name = 'dcim/device_component_list.html'
action_buttons = ('import', 'export')
class ConsoleServerPortCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_consoleserverport'
parent_model = Device
parent_field = 'device'
model = ConsoleServerPort
form = forms.ConsoleServerPortCreateForm
model_form = forms.ConsoleServerPortForm
@@ -1282,7 +1369,6 @@ class ConsoleServerPortBulkImportView(PermissionRequiredMixin, BulkImportView):
class ConsoleServerPortBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_consoleserverport'
queryset = ConsoleServerPort.objects.all()
parent_model = Device
table = tables.ConsoleServerPortTable
form = forms.ConsoleServerPortBulkEditForm
@@ -1302,8 +1388,8 @@ class ConsoleServerPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnec
class ConsoleServerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_consoleserverport'
queryset = ConsoleServerPort.objects.all()
parent_model = Device
table = tables.ConsoleServerPortTable
default_return_url = 'dcim:consoleserverport_list'
#
@@ -1316,13 +1402,11 @@ class PowerPortListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.PowerPortFilterSet
filterset_form = forms.PowerPortFilterForm
table = tables.PowerPortDetailTable
template_name = 'dcim/device_component_list.html'
action_buttons = ('import', 'export')
class PowerPortCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_powerport'
parent_model = Device
parent_field = 'device'
model = PowerPort
form = forms.PowerPortCreateForm
model_form = forms.PowerPortForm
@@ -1347,11 +1431,18 @@ class PowerPortBulkImportView(PermissionRequiredMixin, BulkImportView):
default_return_url = 'dcim:powerport_list'
class PowerPortBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_powerport'
queryset = PowerPort.objects.all()
table = tables.PowerPortTable
form = forms.PowerPortBulkEditForm
class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_powerport'
queryset = PowerPort.objects.all()
parent_model = Device
table = tables.PowerPortTable
default_return_url = 'dcim:powerport_list'
#
@@ -1364,13 +1455,11 @@ class PowerOutletListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.PowerOutletFilterSet
filterset_form = forms.PowerOutletFilterForm
table = tables.PowerOutletDetailTable
template_name = 'dcim/device_component_list.html'
action_buttons = ('import', 'export')
class PowerOutletCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_poweroutlet'
parent_model = Device
parent_field = 'device'
model = PowerOutlet
form = forms.PowerOutletCreateForm
model_form = forms.PowerOutletForm
@@ -1398,7 +1487,6 @@ class PowerOutletBulkImportView(PermissionRequiredMixin, BulkImportView):
class PowerOutletBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_poweroutlet'
queryset = PowerOutlet.objects.all()
parent_model = Device
table = tables.PowerOutletTable
form = forms.PowerOutletBulkEditForm
@@ -1418,8 +1506,8 @@ class PowerOutletBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView)
class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_poweroutlet'
queryset = PowerOutlet.objects.all()
parent_model = Device
table = tables.PowerOutletTable
default_return_url = 'dcim:poweroutlet_list'
#
@@ -1432,7 +1520,7 @@ class InterfaceListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.InterfaceFilterSet
filterset_form = forms.InterfaceFilterForm
table = tables.InterfaceDetailTable
template_name = 'dcim/device_component_list.html'
action_buttons = ('import', 'export')
class InterfaceView(PermissionRequiredMixin, View):
@@ -1473,8 +1561,6 @@ class InterfaceView(PermissionRequiredMixin, View):
class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_interface'
parent_model = Device
parent_field = 'device'
model = Interface
form = forms.InterfaceCreateForm
model_form = forms.InterfaceForm
@@ -1503,7 +1589,6 @@ class InterfaceBulkImportView(PermissionRequiredMixin, BulkImportView):
class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_interface'
queryset = Interface.objects.all()
parent_model = Device
table = tables.InterfaceTable
form = forms.InterfaceBulkEditForm
@@ -1523,8 +1608,8 @@ class InterfaceBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_interface'
queryset = Interface.objects.all()
parent_model = Device
table = tables.InterfaceTable
default_return_url = 'dcim:interface_list'
#
@@ -1537,13 +1622,11 @@ class FrontPortListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.FrontPortFilterSet
filterset_form = forms.FrontPortFilterForm
table = tables.FrontPortDetailTable
template_name = 'dcim/device_component_list.html'
action_buttons = ('import', 'export')
class FrontPortCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_frontport'
parent_model = Device
parent_field = 'device'
model = FrontPort
form = forms.FrontPortCreateForm
model_form = forms.FrontPortForm
@@ -1571,7 +1654,6 @@ class FrontPortBulkImportView(PermissionRequiredMixin, BulkImportView):
class FrontPortBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_frontport'
queryset = FrontPort.objects.all()
parent_model = Device
table = tables.FrontPortTable
form = forms.FrontPortBulkEditForm
@@ -1591,8 +1673,8 @@ class FrontPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
class FrontPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_frontport'
queryset = FrontPort.objects.all()
parent_model = Device
table = tables.FrontPortTable
default_return_url = 'dcim:frontport_list'
#
@@ -1605,13 +1687,11 @@ class RearPortListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.RearPortFilterSet
filterset_form = forms.RearPortFilterForm
table = tables.RearPortDetailTable
template_name = 'dcim/device_component_list.html'
action_buttons = ('import', 'export')
class RearPortCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_rearport'
parent_model = Device
parent_field = 'device'
model = RearPort
form = forms.RearPortCreateForm
model_form = forms.RearPortForm
@@ -1639,7 +1719,6 @@ class RearPortBulkImportView(PermissionRequiredMixin, BulkImportView):
class RearPortBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_rearport'
queryset = RearPort.objects.all()
parent_model = Device
table = tables.RearPortTable
form = forms.RearPortBulkEditForm
@@ -1659,8 +1738,8 @@ class RearPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
class RearPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_rearport'
queryset = RearPort.objects.all()
parent_model = Device
table = tables.RearPortTable
default_return_url = 'dcim:rearport_list'
#
@@ -1675,13 +1754,11 @@ class DeviceBayListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.DeviceBayFilterSet
filterset_form = forms.DeviceBayFilterForm
table = tables.DeviceBayDetailTable
template_name = 'dcim/device_component_list.html'
action_buttons = ('import', 'export')
class DeviceBayCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_devicebay'
parent_model = Device
parent_field = 'device'
model = DeviceBay
form = forms.DeviceBayCreateForm
model_form = forms.DeviceBayForm
@@ -1784,8 +1861,8 @@ class DeviceBayBulkRenameView(PermissionRequiredMixin, BulkRenameView):
class DeviceBayBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_devicebay'
queryset = DeviceBay.objects.all()
parent_model = Device
table = tables.DeviceBayTable
default_return_url = 'dcim:devicebay_list'
#
@@ -1876,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):
@@ -2148,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):
@@ -2156,13 +2233,13 @@ class InventoryItemEditView(PermissionRequiredMixin, ObjectEditView):
model = InventoryItem
model_form = forms.InventoryItemForm
def alter_obj(self, obj, request, url_args, url_kwargs):
if 'device' in url_kwargs:
obj.device = get_object_or_404(Device, pk=url_kwargs['device'])
return obj
def get_return_url(self, request, obj):
return reverse('dcim:device_inventory', kwargs={'pk': obj.device.pk})
class InventoryItemCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_inventoryitem'
model = InventoryItem
form = forms.InventoryItemCreateForm
model_form = forms.InventoryItemForm
template_name = 'dcim/device_component_add.html'
class InventoryItemDeleteView(PermissionRequiredMixin, ObjectDeleteView):
@@ -2204,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):
@@ -2448,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):
@@ -2517,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

@@ -15,34 +15,34 @@ router = routers.DefaultRouter()
router.APIRootView = ExtrasRootView
# Field choices
router.register(r'_choices', views.ExtrasFieldChoicesViewSet, basename='field-choice')
router.register('_choices', views.ExtrasFieldChoicesViewSet, basename='field-choice')
# Custom field choices
router.register(r'_custom_field_choices', views.CustomFieldChoicesViewSet, basename='custom-field-choice')
router.register('_custom_field_choices', views.CustomFieldChoicesViewSet, basename='custom-field-choice')
# Graphs
router.register(r'graphs', views.GraphViewSet)
router.register('graphs', views.GraphViewSet)
# Export templates
router.register(r'export-templates', views.ExportTemplateViewSet)
router.register('export-templates', views.ExportTemplateViewSet)
# Tags
router.register(r'tags', views.TagViewSet)
router.register('tags', views.TagViewSet)
# Image attachments
router.register(r'image-attachments', views.ImageAttachmentViewSet)
router.register('image-attachments', views.ImageAttachmentViewSet)
# Config contexts
router.register(r'config-contexts', views.ConfigContextViewSet)
router.register('config-contexts', views.ConfigContextViewSet)
# Reports
router.register(r'reports', views.ReportViewSet, basename='report')
router.register('reports', views.ReportViewSet, basename='report')
# Scripts
router.register(r'scripts', views.ScriptViewSet, basename='script')
router.register('scripts', views.ScriptViewSet, basename='script')
# Change logging
router.register(r'object-changes', views.ObjectChangeViewSet)
router.register('object-changes', views.ObjectChangeViewSet)
app_name = 'extras-api'
urlpatterns = router.urls

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

@@ -1,14 +1,15 @@
from django import forms
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from mptt.forms import TreeNodeMultipleChoiceField
from taggit.forms import TagField
from dcim.models import DeviceRole, Platform, Region, Site
from tenancy.models import Tenant, TenantGroup
from utilities.forms import (
add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect,
CommentField, ContentTypeSelect, DateTimePicker, FilterChoiceField, JSONField, SlugField, StaticSelect2,
BOOLEAN_WITH_BLANK_CHOICES,
CommentField, ContentTypeSelect, DateTimePicker, DynamicModelMultipleChoiceField, JSONField, SlugField,
StaticSelect2, StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES,
)
from virtualization.models import Cluster, ClusterGroup
from .choices import *
@@ -133,7 +134,8 @@ class CustomFieldFilterForm(forms.Form):
filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED
)
for cf in custom_fields:
self.fields[cf.name] = cf.to_form_field(set_initial=True, enforce_required=False)
field_name = 'cf_{}'.format(cf.name)
self.fields[field_name] = cf.to_form_field(set_initial=True, enforce_required=False)
#
@@ -189,7 +191,61 @@ class TagBulkEditForm(BootstrapMixin, BulkEditForm):
#
class ConfigContextForm(BootstrapMixin, forms.ModelForm):
tags = forms.ModelMultipleChoiceField(
regions = TreeNodeMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
widget=StaticSelect2Multiple()
)
sites = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
required=False,
widget=APISelectMultiple(
api_url="/api/dcim/sites/"
)
)
roles = DynamicModelMultipleChoiceField(
queryset=DeviceRole.objects.all(),
required=False,
widget=APISelectMultiple(
api_url="/api/dcim/device-roles/"
)
)
platforms = DynamicModelMultipleChoiceField(
queryset=Platform.objects.all(),
required=False,
widget=APISelectMultiple(
api_url="/api/dcim/platforms/"
)
)
cluster_groups = DynamicModelMultipleChoiceField(
queryset=ClusterGroup.objects.all(),
required=False,
widget=APISelectMultiple(
api_url="/api/virtualization/cluster-groups/"
)
)
clusters = DynamicModelMultipleChoiceField(
queryset=Cluster.objects.all(),
required=False,
widget=APISelectMultiple(
api_url="/api/virtualization/clusters/"
)
)
tenant_groups = DynamicModelMultipleChoiceField(
queryset=TenantGroup.objects.all(),
required=False,
widget=APISelectMultiple(
api_url="/api/tenancy/tenant-groups/"
)
)
tenants = DynamicModelMultipleChoiceField(
queryset=Tenant.objects.all(),
required=False,
widget=APISelectMultiple(
api_url="/api/tenancy/tenants/"
)
)
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
to_field_name='slug',
required=False,
@@ -203,36 +259,10 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = ConfigContext
fields = [
fields = (
'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'cluster_groups',
'clusters', 'tenant_groups', 'tenants', 'tags', 'data',
]
widgets = {
'regions': APISelectMultiple(
api_url="/api/dcim/regions/"
),
'sites': APISelectMultiple(
api_url="/api/dcim/sites/"
),
'roles': APISelectMultiple(
api_url="/api/dcim/device-roles/"
),
'platforms': APISelectMultiple(
api_url="/api/dcim/platforms/"
),
'cluster_groups': APISelectMultiple(
api_url="/api/virtualization/cluster-groups/"
),
'clusters': APISelectMultiple(
api_url="/api/virtualization/clusters/"
),
'tenant_groups': APISelectMultiple(
api_url="/api/tenancy/tenant-groups/"
),
'tenants': APISelectMultiple(
api_url="/api/tenancy/tenants/"
),
}
)
class ConfigContextBulkEditForm(BootstrapMixin, BulkEditForm):
@@ -264,72 +294,81 @@ class ConfigContextFilterForm(BootstrapMixin, forms.Form):
required=False,
label='Search'
)
region = FilterChoiceField(
region = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
api_url="/api/dcim/regions/",
value_field="slug",
)
)
site = FilterChoiceField(
site = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
api_url="/api/dcim/sites/",
value_field="slug",
)
)
role = FilterChoiceField(
role = DynamicModelMultipleChoiceField(
queryset=DeviceRole.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
api_url="/api/dcim/device-roles/",
value_field="slug",
)
)
platform = FilterChoiceField(
platform = DynamicModelMultipleChoiceField(
queryset=Platform.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
api_url="/api/dcim/platforms/",
value_field="slug",
)
)
cluster_group = FilterChoiceField(
cluster_group = DynamicModelMultipleChoiceField(
queryset=ClusterGroup.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
api_url="/api/virtualization/cluster-groups/",
value_field="slug",
)
)
cluster_id = FilterChoiceField(
cluster_id = DynamicModelMultipleChoiceField(
queryset=Cluster.objects.all(),
required=False,
label='Cluster',
widget=APISelectMultiple(
api_url="/api/virtualization/clusters/",
)
)
tenant_group = FilterChoiceField(
tenant_group = DynamicModelMultipleChoiceField(
queryset=TenantGroup.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
api_url="/api/tenancy/tenant-groups/",
value_field="slug",
)
)
tenant = FilterChoiceField(
tenant = DynamicModelMultipleChoiceField(
queryset=Tenant.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
api_url="/api/tenancy/tenants/",
value_field="slug",
)
)
tag = FilterChoiceField(
tag = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
api_url="/api/extras/tags/",
value_field="slug",
@@ -386,11 +425,14 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
)
action = forms.ChoiceField(
choices=add_blank_choice(ObjectChangeActionChoices),
required=False
required=False,
widget=StaticSelect2()
)
# TODO: Convert to DynamicModelMultipleChoiceField once we have an API endpoint for users
user = forms.ModelChoiceField(
queryset=User.objects.order_by('username'),
required=False
required=False,
widget=StaticSelect2()
)
changed_object_type = forms.ModelChoiceField(
queryset=ContentType.objects.order_by('model'),

View File

@@ -0,0 +1,111 @@
from django.apps import apps
from django.core.management.base import BaseCommand, CommandError
from utilities.fields import NaturalOrderingField
class Command(BaseCommand):
help = "Recalculate natural ordering values for the specified models"
def add_arguments(self, parser):
parser.add_argument(
'args', metavar='app_label.ModelName', nargs='*',
help='One or more specific models (each prefixed with its app_label) to renaturalize',
)
def _get_models(self, names):
"""
Compile a list of models to be renaturalized. If no names are specified, all models which have one or more
NaturalOrderingFields will be included.
"""
models = []
if names:
# Collect all NaturalOrderingFields present on the specified models
for name in names:
try:
app_label, model_name = name.split('.')
except ValueError:
raise CommandError(
"Invalid format: {}. Models must be specified in the form app_label.ModelName.".format(name)
)
try:
app_config = apps.get_app_config(app_label)
except LookupError as e:
raise CommandError(str(e))
try:
model = app_config.get_model(model_name)
except LookupError:
raise CommandError("Unknown model: {}.{}".format(app_label, model_name))
fields = [
field for field in model._meta.concrete_fields if type(field) is NaturalOrderingField
]
if not fields:
raise CommandError(
"Invalid model: {}.{} does not employ natural ordering".format(app_label, model_name)
)
models.append(
(model, fields)
)
else:
# Find *all* models with NaturalOrderingFields
for app_config in apps.get_app_configs():
for model in app_config.models.values():
fields = [
field for field in model._meta.concrete_fields if type(field) is NaturalOrderingField
]
if fields:
models.append(
(model, fields)
)
return models
def handle(self, *args, **options):
models = self._get_models(args)
if options['verbosity']:
self.stdout.write("Renaturalizing {} models.".format(len(models)))
for model, fields in models:
for field in fields:
target_field = field.target_field
naturalize = field.naturalize_function
count = 0
# Print the model and field name
if options['verbosity']:
self.stdout.write(
"{}.{} ({})... ".format(model._meta.label, field.target_field, field.name),
ending='\n' if options['verbosity'] >= 2 else ''
)
self.stdout.flush()
# Find all unique values for the field
queryset = model.objects.values_list(target_field, flat=True).order_by(target_field).distinct()
for value in queryset:
naturalized_value = naturalize(value, max_length=field.max_length)
if options['verbosity'] >= 2:
self.stdout.write(" {} -> {}".format(value, naturalized_value), ending='')
self.stdout.flush()
# Update each unique field value in bulk
changed = model.objects.filter(name=value).update(**{field.name: naturalized_value})
if options['verbosity'] >= 2:
self.stdout.write(" ({})".format(changed))
count += changed
# Print the total count of alterations for the field
if options['verbosity'] >= 2:
self.stdout.write(self.style.SUCCESS("{} {} updated ({} unique values)".format(
count, model._meta.verbose_name_plural, queryset.count()
)))
elif options['verbosity']:
self.stdout.write(self.style.SUCCESS(str(count)))
if options['verbosity']:
self.stdout.write(self.style.SUCCESS("Done."))

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

@@ -48,7 +48,7 @@ class ScriptVariable:
"""
form_field = forms.CharField
def __init__(self, label='', description='', default=None, required=True):
def __init__(self, label='', description='', default=None, required=True, widget=None):
# Initialize field attributes
if not hasattr(self, 'field_attrs'):
@@ -59,19 +59,20 @@ class ScriptVariable:
self.field_attrs['help_text'] = description
if default:
self.field_attrs['initial'] = default
if widget:
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.
"""
form_field = self.form_field(**self.field_attrs)
if not isinstance(form_field.widget, forms.CheckboxInput):
form_field.widget.attrs['class'] = 'form-control'
if form_field.widget.attrs and 'class' in form_field.widget.attrs.keys():
form_field.widget.attrs['class'] += ' form-control'
else:
form_field.widget.attrs['class'] = 'form-control'
return form_field
@@ -222,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)
@@ -287,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):
@@ -384,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

@@ -7,10 +7,10 @@ from django.urls import reverse
from dcim.models import Site
from extras.choices import ObjectChangeActionChoices
from extras.models import ConfigContext, ObjectChange, Tag
from utilities.testing import StandardTestCases, TestCase
from utilities.testing import ViewTestCases, TestCase
class TagTestCase(StandardTestCases.Views):
class TagTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = Tag
# Disable inapplicable tests
@@ -38,7 +38,7 @@ class TagTestCase(StandardTestCases.Views):
}
class ConfigContextTestCase(StandardTestCases.Views):
class ConfigContextTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = ConfigContext
# Disable inapplicable tests

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

@@ -8,38 +8,38 @@ app_name = 'extras'
urlpatterns = [
# Tags
path(r'tags/', views.TagListView.as_view(), name='tag_list'),
path(r'tags/edit/', views.TagBulkEditView.as_view(), name='tag_bulk_edit'),
path(r'tags/delete/', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'),
path(r'tags/<str:slug>/', views.TagView.as_view(), name='tag'),
path(r'tags/<str:slug>/edit/', views.TagEditView.as_view(), name='tag_edit'),
path(r'tags/<str:slug>/delete/', views.TagDeleteView.as_view(), name='tag_delete'),
path(r'tags/<str:slug>/changelog/', views.ObjectChangeLogView.as_view(), name='tag_changelog', kwargs={'model': Tag}),
path('tags/', views.TagListView.as_view(), name='tag_list'),
path('tags/edit/', views.TagBulkEditView.as_view(), name='tag_bulk_edit'),
path('tags/delete/', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'),
path('tags/<str:slug>/', views.TagView.as_view(), name='tag'),
path('tags/<str:slug>/edit/', views.TagEditView.as_view(), name='tag_edit'),
path('tags/<str:slug>/delete/', views.TagDeleteView.as_view(), name='tag_delete'),
path('tags/<str:slug>/changelog/', views.ObjectChangeLogView.as_view(), name='tag_changelog', kwargs={'model': Tag}),
# Config contexts
path(r'config-contexts/', views.ConfigContextListView.as_view(), name='configcontext_list'),
path(r'config-contexts/add/', views.ConfigContextCreateView.as_view(), name='configcontext_add'),
path(r'config-contexts/edit/', views.ConfigContextBulkEditView.as_view(), name='configcontext_bulk_edit'),
path(r'config-contexts/delete/', views.ConfigContextBulkDeleteView.as_view(), name='configcontext_bulk_delete'),
path(r'config-contexts/<int:pk>/', views.ConfigContextView.as_view(), name='configcontext'),
path(r'config-contexts/<int:pk>/edit/', views.ConfigContextEditView.as_view(), name='configcontext_edit'),
path(r'config-contexts/<int:pk>/delete/', views.ConfigContextDeleteView.as_view(), name='configcontext_delete'),
path('config-contexts/', views.ConfigContextListView.as_view(), name='configcontext_list'),
path('config-contexts/add/', views.ConfigContextCreateView.as_view(), name='configcontext_add'),
path('config-contexts/edit/', views.ConfigContextBulkEditView.as_view(), name='configcontext_bulk_edit'),
path('config-contexts/delete/', views.ConfigContextBulkDeleteView.as_view(), name='configcontext_bulk_delete'),
path('config-contexts/<int:pk>/', views.ConfigContextView.as_view(), name='configcontext'),
path('config-contexts/<int:pk>/edit/', views.ConfigContextEditView.as_view(), name='configcontext_edit'),
path('config-contexts/<int:pk>/delete/', views.ConfigContextDeleteView.as_view(), name='configcontext_delete'),
# Image attachments
path(r'image-attachments/<int:pk>/edit/', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'),
path(r'image-attachments/<int:pk>/delete/', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'),
path('image-attachments/<int:pk>/edit/', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'),
path('image-attachments/<int:pk>/delete/', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'),
# Change logging
path(r'changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),
path(r'changelog/<int:pk>/', views.ObjectChangeView.as_view(), name='objectchange'),
path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),
path('changelog/<int:pk>/', views.ObjectChangeView.as_view(), name='objectchange'),
# Reports
path(r'reports/', views.ReportListView.as_view(), name='report_list'),
path(r'reports/<str:name>/', views.ReportView.as_view(), name='report'),
path(r'reports/<str:name>/run/', views.ReportRunView.as_view(), name='report_run'),
path('reports/', views.ReportListView.as_view(), name='report_list'),
path('reports/<str:name>/', views.ReportView.as_view(), name='report'),
path('reports/<str:name>/run/', views.ReportRunView.as_view(), name='report_run'),
# Scripts
path(r'scripts/', views.ScriptListView.as_view(), name='script_list'),
path(r'scripts/<str:module>/<str:name>/', views.ScriptView.as_view(), name='script'),
path('scripts/', views.ScriptListView.as_view(), name='script_list'),
path('scripts/<str:module>/<str:name>/', views.ScriptView.as_view(), name='script'),
]

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

@@ -202,7 +202,7 @@ class IPAddressSerializer(TaggitSerializer, CustomFieldModelSerializer):
vrf = NestedVRFSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer(required=False, allow_null=True)
status = ChoiceField(choices=IPAddressStatusChoices, required=False)
role = ChoiceField(choices=IPAddressRoleChoices, required=False, allow_null=True)
role = ChoiceField(choices=IPAddressRoleChoices, allow_blank=True, required=False)
interface = IPAddressInterfaceSerializer(required=False, allow_null=True)
nat_inside = NestedIPAddressSerializer(required=False, allow_null=True)
nat_outside = NestedIPAddressSerializer(read_only=True)
@@ -240,7 +240,7 @@ class AvailableIPSerializer(serializers.Serializer):
class ServiceSerializer(TaggitSerializer, CustomFieldModelSerializer):
device = NestedDeviceSerializer(required=False, allow_null=True)
virtual_machine = NestedVirtualMachineSerializer(required=False, allow_null=True)
protocol = ChoiceField(choices=ServiceProtocolChoices)
protocol = ChoiceField(choices=ServiceProtocolChoices, required=False)
ipaddresses = SerializedPKRelatedField(
queryset=IPAddress.objects.all(),
serializer=NestedIPAddressSerializer,

View File

@@ -15,30 +15,30 @@ router = routers.DefaultRouter()
router.APIRootView = IPAMRootView
# Field choices
router.register(r'_choices', views.IPAMFieldChoicesViewSet, basename='field-choice')
router.register('_choices', views.IPAMFieldChoicesViewSet, basename='field-choice')
# VRFs
router.register(r'vrfs', views.VRFViewSet)
router.register('vrfs', views.VRFViewSet)
# RIRs
router.register(r'rirs', views.RIRViewSet)
router.register('rirs', views.RIRViewSet)
# Aggregates
router.register(r'aggregates', views.AggregateViewSet)
router.register('aggregates', views.AggregateViewSet)
# Prefixes
router.register(r'roles', views.RoleViewSet)
router.register(r'prefixes', views.PrefixViewSet)
router.register('roles', views.RoleViewSet)
router.register('prefixes', views.PrefixViewSet)
# IP addresses
router.register(r'ip-addresses', views.IPAddressViewSet)
router.register('ip-addresses', views.IPAddressViewSet)
# VLANs
router.register(r'vlan-groups', views.VLANGroupViewSet)
router.register(r'vlans', views.VLANViewSet)
router.register('vlan-groups', views.VLANGroupViewSet)
router.register('vlans', views.VLANViewSet)
# Services
router.register(r'services', views.ServiceViewSet)
router.register('services', views.ServiceViewSet)
app_name = 'ipam-api'
urlpatterns = router.urls

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, 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'
@@ -304,12 +307,12 @@ class IPAddressFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedF
to_field_name='rd',
label='VRF (RD)',
)
device = django_filters.CharFilter(
device = MultiValueCharFilter(
method='filter_device',
field_name='name',
label='Device',
label='Device (name)',
)
device_id = django_filters.NumberFilter(
device_id = MultiValueNumberFilter(
method='filter_device',
field_name='pk',
label='Device (ID)',
@@ -385,8 +388,10 @@ class IPAddressFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedF
def filter_device(self, queryset, name, value):
try:
device = Device.objects.prefetch_related('device_type').get(**{name: value})
vc_interface_ids = [i['id'] for i in device.vc_interfaces.values('id')]
devices = Device.objects.prefetch_related('device_type').filter(**{'{}__in'.format(name): value})
vc_interface_ids = []
for device in devices:
vc_interface_ids.extend([i['id'] for i in device.vc_interfaces.values('id')])
return queryset.filter(interface_id__in=vc_interface_ids)
except Device.DoesNotExist:
return queryset.none()
@@ -395,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)',
)
@@ -423,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'
@@ -434,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)',
)
@@ -494,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',

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