Compare commits

...

258 Commits

Author SHA1 Message Date
jeremystretch
f11a6f0135 Release v3.3-beta2 2022-08-03 14:41:50 -04:00
Jeremy Stretch
44850feaf8 Merge pull request #9904 from netbox-community/9888-site_device_filters
Fixes: #9888 - Add filter and columns for device and site
2022-08-03 13:31:58 -04:00
jeremystretch
367bf25618 Fixes #9778: Fix exception during cable deletion after deleting a connected termination 2022-08-03 12:46:16 -04:00
jeremystretch
b9678c7c0e Closes #9853: Show full object in cable A/B termination lists 2022-08-03 10:50:35 -04:00
Daniel Sheppard
37c4f1a7d3 Fix up a few minor mistakes. Add tests. 2022-08-02 13:38:17 -05:00
jeremystretch
d3a567a2f5 Fixes #9788: Ensure denormalized fields on CableTermination are kept in sync with related objects 2022-08-02 13:56:52 -04:00
Daniel Sheppard
5b3ef04550 #9888 - Add filter and columns for device and site 2022-08-02 12:56:10 -05:00
jeremystretch
e96620260a Closes #9903: Implement a mechanism for automatically updating denormalized fields 2022-08-02 13:49:34 -04:00
jeremystretch
29a611c729 Closes #9896: Discontinue arbitrary use of OrderedDict 2022-08-01 16:51:44 -04:00
jeremystretch
562769fb89 Merge branch 'develop' into feature 2022-08-01 14:42:09 -04:00
Jeremy Stretch
4591237bfd Merge pull request #9886 from hagbarddenstore/issue_9699
Remove deprecated usage of prefetch_related
2022-08-01 13:46:38 -04:00
jeremystretch
262a0cf397 Fixes #9789: Fix rendering of cable traces ending at provider networks 2022-08-01 13:29:39 -04:00
jeremystretch
ff3fcb8134 #9871: Tweak display of utilization graph value 2022-08-01 12:38:12 -04:00
jeremystretch
d4d73674fc Fixes #9871: Fix utilization graph value alignments 2022-08-01 11:54:39 -04:00
jeremystretch
984d15d7fb Closes #9893: Move YAML serialization logic for component templates under the individual models 2022-08-01 11:39:07 -04:00
jeremystretch
efa449faff Closes #9882: Add manufacturer column to modules table 2022-08-01 10:36:53 -04:00
jeremystretch
3af989763e Closes #9883: Linkify location column in power panels table 2022-08-01 10:32:05 -04:00
jeremystretch
9646f88384 Fixes #9885: Fix child prefix counts when editing/deleting aggregates in bulk 2022-08-01 10:28:21 -04:00
jeremystretch
1bbf5d214b Closes #9881: Increase granularity in utilization graph values 2022-08-01 10:23:18 -04:00
jeremystretch
8a075bcff9 Fixes #9884: Prevent querying assigned VRF on prefix object init 2022-08-01 09:47:18 -04:00
jeremystretch
9fe5f09742 Fixes #9891: Ensure consistent ordering for tags during object serialization 2022-08-01 09:32:52 -04:00
jeremystretch
84f2225f42 PEP8 cleanup 2022-08-01 09:16:58 -04:00
jeremystretch
728ad51624 Changelog & cleanup for #9637 2022-08-01 09:12:15 -04:00
atownson
5ab03b7e92 Closes #9637: Add existing fields to the Rack Reservation user interface pages (#9870)
* Closes netbox-community#9637

Added site_group to RackReservationFrom class fieldsets
Added location to RackReservationTable class
2022-08-01 09:01:18 -04:00
Kim Johansson
6904666e2a Remove deprecated usage of prefetch_related
Fixes #9699
2022-07-30 01:18:30 +02:00
Daniel Sheppard
890efa5400 Fixes #9062 - Add/edit {module} substitution to help text for component template name 2022-07-29 11:55:26 -05:00
jeremystretch
04fb0bd51c Closes #9858: ChangeLoggedModelFilterSet cleanup 2022-07-28 15:41:10 -04:00
jeremystretch
2c43c8d077 Closes #9793: Add PoE attributes to interface templates 2022-07-28 15:20:25 -04:00
jeremystretch
c5fb7b72f0 Closes #9391: Remove 500-character limit for custom link text & URL fields 2022-07-28 14:36:20 -04:00
jeremystretch
07620db027 Changelog for #9762 2022-07-28 12:45:27 -04:00
Jeremy Stretch
f8a3ffae4e Merge pull request #9868 from atownson/issue_9762
Closes #9762: Add nat_outside to the IPAddress table
2022-07-28 12:43:13 -04:00
atownson
62d1510c55 Closes netbox-community#9762
Added nat_outside to IPAddressTable class
2022-07-28 10:23:04 -05:00
jeremystretch
498b655cb7 Changelog and cleanup for #9825 2022-07-27 16:50:31 -04:00
Jeremy Stretch
fa94d9c82c Merge pull request #9826 from viroge/develop
Closes #9825: Add Contacts to VM table view
2022-07-27 16:48:08 -04:00
jeremystretch
6cee12b153 Fix formatting of webhook conditions form field 2022-07-27 15:40:25 -04:00
jeremystretch
a6be8dccf5 Fixes #9847: Respect desc_units when ordering rack units 2022-07-26 15:45:47 -04:00
jeremystretch
466931d2fb Fixes #9829: Arrange custom fields by group when editing objects 2022-07-26 12:41:51 -04:00
jeremystretch
d442f8fd60 Fixes #9843: Fix rendering of custom field values (regression from #9647) 2022-07-26 11:09:51 -04:00
jeremystretch
7631722f97 Merge branch 'temp' into feature 2022-07-26 08:36:50 -04:00
Daniel Sheppard
6d30c07dd0 Update changelog for #9844 2022-07-26 07:29:18 -05:00
Daniel Sheppard
6f7289f932 Fixes #9844 - Add dedicated device_vlan form field 2022-07-26 07:22:21 -05:00
jeremystretch
2583abc39d Fix null cable termination representation 2022-07-25 11:34:16 -04:00
jeremystretch
12476036cd Changelog for #9794, #9818 2022-07-25 09:55:20 -04:00
Jeremy Stretch
91070f823a Merge pull request #9819 from jaylik/jaylik-9818
Fixes #9818: Typo fix in CableForm
2022-07-25 09:52:42 -04:00
Jeremy Stretch
8df4966a2b Merge pull request #9796 from mzbroch/mzb-9102,4
Fixes 9794: Typo fix in REARPORT_BUTTONS template code
2022-07-25 09:50:27 -04:00
Gabor SOMOGYVARI
451a0067c7 Closes #9825: Add Contacts to VM table view 2022-07-22 10:42:20 +02:00
Juho Ylikorpi
e2580ea469 fixes #9818 2022-07-21 16:54:23 +03:00
jeremystretch
abf11fbcb8 Merge branch 'develop' into feature 2022-07-20 12:20:33 -04:00
jeremystretch
383918d83b PRVB 2022-07-20 11:15:02 -04:00
Marek Zbroch
e92b7f8bb9 Typo fix in REARPORT_BUTTONS template code 2022-07-20 13:02:10 +02:00
Jeremy Stretch
abfa6a325a Merge pull request #9748 from mzbroch/mzb-9102
Fixes #9768: Typo fix in CableEditView
2022-07-19 10:05:14 -04:00
jeremystretch
0e18292e41 Add summary release notes for v3.3 2022-07-18 13:09:45 -04:00
jeremystretch
6d53788ea2 Changelog for #9765 2022-07-18 13:05:12 -04:00
Jeremy Stretch
fae9874dde Merge pull request #9766 from kkthxbye-code/fix-9765
Fixes #9765 - Fix "Total segment" count on trace view
2022-07-18 13:04:01 -04:00
jeremystretch
b8da66bb55 Fixes #9733: Handle split paths during trace when fanning out to front ports with differing cables 2022-07-18 11:51:59 -04:00
kkthxbye-code
8a2276e791 Use segment_count for segment count on trace view 2022-07-18 16:17:16 +02:00
Jeremy Stretch
4bdef80554 Merge pull request #9752 from mzbroch/mzb-9102#2
Fixes #9730 - Fix CableForm Validation for #9102
2022-07-18 10:03:48 -04:00
Marek Zbroch
58b191b439 Fix CableForm Validation for #9102 2022-07-17 09:28:58 +02:00
Marek Zbroch
f385a5fd5e Typo fix in CableEditView 2022-07-16 09:42:01 +02:00
jeremystretch
68b87dd668 Fixes #9729: Fix ordering of content type creation to ensure compatability with demo data 2022-07-15 13:27:20 -04:00
jeremystretch
024e7d8651 Fixes #9728: Fix validation when assigning a virtual machine to a device 2022-07-15 10:19:56 -04:00
jeremystretch
e8dd952aa5 Fix migration progress output 2022-07-15 10:07:35 -04:00
jeremystretch
868e94fb73 Release v3.3-beta1 2022-07-14 12:04:53 -04:00
jeremystretch
15395a56e7 Move L2VPN into new menu group 2022-07-14 11:02:00 -04:00
jeremystretch
29c81a788f Clean up connection tables 2022-07-14 10:37:27 -04:00
jeremystretch
0615252e15 Add progress output to cable migration 2022-07-14 10:33:20 -04:00
jeremystretch
be00d1f3c6 Fix erroneous import 2022-07-14 08:36:16 -04:00
jeremystretch
3eb6b6c07f Clean up core API imports 2022-07-13 16:18:55 -04:00
jeremystretch
0b86326435 #9102: Enable creating terminations in conjunction with cables via REST API 2022-07-13 15:35:37 -04:00
jeremystretch
fb2bfe2337 Introduce GenericObjectSerializer 2022-07-13 15:12:53 -04:00
jeremystretch
43a4a9c86d Complete release notes 2022-07-12 16:29:43 -04:00
jeremystretch
43b27cc052 Misc cleanup 2022-07-12 16:29:26 -04:00
jeremystretch
53372a7471 #8157: General cleanup & fix tests 2022-07-11 21:51:39 -04:00
jeremystretch
1ddb219a0c Documentation cleanup 2022-07-11 17:29:25 -04:00
jeremystretch
2264937f81 Consolidate custom field migrations 2022-07-11 17:11:58 -04:00
jeremystretch
8a8ada8529 #9177: Use TenancyColumnsMixin (from #9686) 2022-07-11 16:14:46 -04:00
jeremystretch
123e758c6d Closes #9708: Render user API tokens in a table 2022-07-11 15:43:59 -04:00
jeremystretch
531d961d30 Add API tokens to user menu 2022-07-11 14:47:15 -04:00
jeremystretch
c380fd00bf Merge branch 'develop' into feature 2022-07-11 12:58:24 -04:00
jeremystretch
29eb37857c Merge branch 'develop' into feature 2022-07-08 16:06:27 -04:00
Jeremy Stretch
6c9f2734a2 Merge pull request #9615 from netbox-community/9102-cabling
Closes #9102: Add support for multi-termination cable ends
2022-07-08 14:59:38 -04:00
jeremystretch
f386ec82ae Fix CablePath serialization for pass-through port paths 2022-07-08 13:55:13 -04:00
jeremystretch
ac4a87de13 Improve display of related objects in cables list 2022-07-08 13:33:43 -04:00
jeremystretch
42e5282283 Improve filtering cables by termination device/rack/site 2022-07-08 11:44:28 -04:00
jeremystretch
11707cb3b1 Fix trace() loop 2022-07-07 19:08:20 -04:00
jeremystretch
d444aa1110 Fix support for tracing across bridge relationships 2022-07-07 14:35:02 -04:00
jeremystretch
a858e041e4 Cache properties which return related objects 2022-07-07 14:23:05 -04:00
jeremystretch
13ef5dc0b3 Extend Interface.link_peers() to support WirelessLinks 2022-07-07 13:57:39 -04:00
jeremystretch
9a7f3f8c1a Cleanup for #9102 2022-07-07 12:48:44 -04:00
jeremystretch
1beb8522b9 #9102: Document API changes 2022-07-06 16:02:53 -04:00
jeremystretch
af14bfdd84 Rename LinkTermination to CabledObjectModel 2022-07-06 15:56:10 -04:00
jeremystretch
ba079b9ee5 Merge branch 'feature' into 9102-cabling 2022-07-06 14:09:25 -04:00
jeremystretch
78a3dfc5d9 Changelog for #8157 2022-07-06 13:38:25 -04:00
jeremystretch
4d3278cb52 #8157: Clean up L2VPN assignment for VM interfaces 2022-07-06 13:31:31 -04:00
Jeremy Stretch
5fd3eb82cd Merge pull request #9631 from netbox-community/8157-l2vpn
Closes: #8157 - Add L2VPN support
2022-07-06 13:14:21 -04:00
jeremystretch
1e1ce550c0 Update L2VPN migration 2022-07-06 12:53:10 -04:00
jeremystretch
4bb4bbce14 Clean up validation 2022-07-06 12:49:00 -04:00
jeremystretch
29a46d2c18 Add support for VM interface termination via bulk import 2022-07-06 12:48:28 -04:00
Daniel Sheppard
878c465c56 Update Termination table rendering on L2VPN View 2022-07-06 09:10:10 -05:00
Daniel Sheppard
f1c8926252 Fix error 2022-07-06 09:01:08 -05:00
Daniel Sheppard
5bcc3a3fb9 Update docs 2022-07-06 09:00:33 -05:00
Daniel Sheppard
30350e3b40 More fixes as a result of code review 2022-07-06 08:57:15 -05:00
Daniel Sheppard
0004b834fb Commit fixes Jeremy suggested
Co-authored-by: Jeremy Stretch <jstretch@ns1.com>
2022-07-06 08:17:50 -05:00
Daniel Sheppard
dbb1773e15 Remove extraneous imports
Co-authored-by: Jeremy Stretch <jstretch@ns1.com>
2022-07-06 08:04:33 -05:00
Daniel Sheppard
8e39e7f830 Change API urls to plural form
Co-authored-by: Jeremy Stretch <jstretch@ns1.com>
2022-07-06 08:03:07 -05:00
Daniel Sheppard
aa856e75e8 Change Virtual Circuits to L2VPN
Co-authored-by: Jeremy Stretch <jstretch@ns1.com>
2022-07-06 08:02:44 -05:00
Daniel Sheppard
b1729f2127 Change Virtual Circuits to L2VPN
Co-authored-by: Jeremy Stretch <jstretch@ns1.com>
2022-07-06 08:02:29 -05:00
jeremystretch
277c2ff869 Closes #8171: Populate next available address when cloning an IP 2022-07-01 16:36:24 -04:00
jeremystretch
23f391c5b5 Closes #9228: Add serialize_object() method to ChangeLoggingMixin 2022-07-01 15:52:16 -04:00
jeremystretch
a5124ab9c8 Closes #8511: Enable custom fields and tags for circuit terminations 2022-07-01 15:10:31 -04:00
jeremystretch
a57398b0d6 Closes #9647: Introduce customfield_value template tag 2022-07-01 14:45:22 -04:00
jeremystretch
c11af40a06 prepare_cloned_fields() should always return a QueryDict 2022-07-01 13:52:37 -04:00
jeremystretch
12c138b341 Closes #9074: Enable referencing the current user when evaluating permission constraints 2022-07-01 13:34:10 -04:00
jeremystretch
c6dfdf10e5 Introduce qs_filter_from_constraints() for constructing object permission QS filters 2022-07-01 11:49:36 -04:00
jeremystretch
ad5fd01ea4 Merge branch 'develop' into feature 2022-07-01 11:37:18 -04:00
jeremystretch
be778353b7 #1099: Restore PoE fields on interface edit form 2022-06-30 17:13:26 -04:00
jeremystretch
3a6f46bf38 Closes #9075: Introduce AbortRequest exception for cleanly interrupting object mutations 2022-06-30 15:15:07 -04:00
jeremystretch
65f4895dd6 Add ActionsMixin to __all__ 2022-06-30 14:35:41 -04:00
Daniel Sheppard
5b397a9827 Add docs 2022-06-30 08:29:08 -05:00
Daniel Sheppard
dd6bfed565 Add migration file 2022-06-29 18:37:31 -05:00
Daniel Sheppard
6e983d1542 Fix up some PEP errors 2022-06-29 16:14:30 -05:00
Daniel Sheppard
3be9f6c4f3 #8157 - Final work on L2VPN model 2022-06-29 16:01:20 -05:00
jeremystretch
779969f150 Closes #9070: Hide navigation menu items based on user permissions 2022-06-29 16:36:10 -04:00
Jeremy Stretch
2245f1bf41 Merge pull request #9630 from netbox-community/9092-ObjectChildrenView
Closes #9092: Introduce support for use of ObjectChildrenView by plugins
2022-06-29 14:49:17 -04:00
jeremystretch
a0f9b5e47b Document support for ObjectChildrenView 2022-06-29 14:30:47 -04:00
jeremystretch
4649bc632c Update templates for subclasses of ObjectChildrenView 2022-06-29 14:21:57 -04:00
jeremystretch
cdcb77dea8 Move actions determination to a mixin 2022-06-29 14:21:30 -04:00
jeremystretch
c5770392e3 Refactor ObjectChildrenView 2022-06-28 18:14:57 -04:00
jeremystretch
cd3111ca8d Merge branch 'develop' into feature 2022-06-28 16:55:49 -04:00
Daniel Sheppard
03f1584d3a L2VPN Clean Tree 2022-06-27 23:24:50 -05:00
jeremystretch
03d6e25dea Remove obsolete validation logic 2022-06-27 16:49:04 -04:00
jeremystretch
8bbc592261 Add SVG rendering to cable path tests 2022-06-27 15:53:34 -04:00
jeremystretch
9d35a9d912 Fix bug with path padding 2022-06-27 15:34:54 -04:00
jeremystretch
7622d90c1d Extend cable tracing to support terminations to different parent objects 2022-06-27 15:22:09 -04:00
jeremystretch
fcd1daaf79 Update power utilization calculations for new cabling model 2022-06-27 14:34:08 -04:00
jeremystretch
25ed3390cb Merge branch 'feature' into 9102-cabling 2022-06-27 12:12:34 -04:00
jeremystretch
7dd5f9e720 Closes #9177: Add tenant assignment for wireless LANs & links 2022-06-27 11:30:52 -04:00
Jeremy Stretch
f6c52e0616 Merge pull request #9606 from netbox-community/4434-rack-device-highlights
Closes #4434: Enable highlighting devices within rack elevations
2022-06-24 11:44:11 -04:00
jeremystretch
fc02e15fb1 Closes #4434: Enable highlighting devices within rack elevations 2022-06-24 11:04:38 -04:00
jeremystretch
f9d81fd362 Closes #9414: Add clone() method to NetBoxModel for copying instance attributes 2022-06-23 15:21:10 -04:00
Jeremy Stretch
12bd3840f9 Merge pull request #9595 from netbox-community/9536-token-last-used
Closes #9536: Record last used time for API tokens
2022-06-23 12:28:30 -04:00
jeremystretch
dc05e62ce0 Documentation & clenaup for #9536 2022-06-23 12:15:02 -04:00
Jeremy Stretch
4f33685ca7 Merge pull request #9554 from PieterL75/issue_9536
Closes #9536: Last Used field on API tokens
2022-06-23 11:53:58 -04:00
Jeremy Stretch
cfb9605e9b Merge branch 'feature' into issue_9536 2022-06-23 10:35:27 -04:00
Jeremy Stretch
f563ba7a9e Merge pull request #9590 from netbox-community/8233-api-token-ip
Closes #8233: Restrict API tokens by source IP
2022-06-23 08:29:45 -04:00
jeremystretch
7e4b34560f Update token model docs 2022-06-23 08:12:36 -04:00
jeremystretch
d4db656940 Allowed IPs should be optional on Token 2022-06-23 08:09:39 -04:00
jeremystretch
3c15419bd0 Introduce IPNetworkSerializer to serialize allowed token IPs 2022-06-22 21:51:43 -04:00
jeremystretch
e3b7bba84f Add token authentication tests 2022-06-22 21:10:18 -04:00
jeremystretch
a38a880e67 Refactor source IP resolution logic 2022-06-22 17:01:07 -04:00
Jeremy Stretch
7043c6faf9 Merge pull request #9175 from PieterL75/issue_8233
Closes #8233: Restrict API key access by source IP
2022-06-22 16:23:59 -04:00
jeremystretch
379880cd84 Closes #9582: Enable assigning config contexts based on device location 2022-06-22 16:10:48 -04:00
jeremystretch
341615668b Closes #7120: Add termination_date field to Circuit 2022-06-22 15:09:50 -04:00
jeremystretch
e4aa933d57 Closes #7744: Add status field to Location 2022-06-22 13:33:19 -04:00
jeremystretch
4587b83d85 Closes #1099: Add PoE mode & type for interfaces 2022-06-21 21:22:24 -04:00
jeremystretch
9c214622a1 Closes #4350: Illustrate reservations vertically alongside rack elevations 2022-06-21 16:30:27 -04:00
jeremystretch
440dfabefe Merge branch 'feature' into 9102-cabling 2022-06-20 15:04:55 -04:00
jeremystretch
64080e808e Refactor dcim.svg module 2022-06-20 14:30:25 -04:00
Jeremy Stretch
7decad1ff3 Merge pull request #9571 from netbox-community/51-half-height-rack-units
Closes #51: Half height rack units
2022-06-20 14:13:44 -04:00
jeremystretch
103729c085 Add test for 0.5U devices 2022-06-20 13:57:37 -04:00
jeremystretch
4ced0bed13 Clean up rack model tests 2022-06-20 12:37:56 -04:00
jeremystretch
0d6d68c62f Fix YAML representation of decimal values 2022-06-20 12:07:59 -04:00
jeremystretch
278891c262 Fix rack utilization calculation 2022-06-20 12:07:59 -04:00
jeremystretch
ae12948558 Refactor device rendering methods 2022-06-20 12:07:59 -04:00
jeremystretch
0c915f7de9 Clean up rack elevation rendering 2022-06-20 12:06:34 -04:00
jeremystretch
84f0561712 Initial work on half-height RUs 2022-06-20 12:05:06 -04:00
jeremystretch
ba12db3019 Merge branch 'develop' into feature 2022-06-20 11:50:23 -04:00
Pieter Lambrecht
81cea9b9d9 Show LastUsed in /user/api-tokens/ 2022-06-19 13:03:03 +02:00
Pieter Lambrecht
ae342a0506 Correct delay time from 6 to 60 seconds 2022-06-19 12:41:44 +02:00
Pieter Lambrecht
f8221340af Disable token last_used update when in Maint mode 2022-06-19 12:40:52 +02:00
PieterL75
536239d272 Merge branch 'feature' into issue_8233 2022-06-17 16:31:13 +02:00
Pieter Lambrecht
d32bbd06cf Fix last_used=None error 2022-06-17 15:52:12 +02:00
Pieter Lambrecht
5d868168a5 Rename timediff to lasted_used_diff 2022-06-17 14:58:20 +02:00
Pieter Lambrecht
5d4575ed25 Only update every 60 seconds 2022-06-17 14:57:19 +02:00
Pieter Lambrecht
c04b4bbbfa Add last_used to Token model and update when used 2022-06-17 14:45:56 +02:00
jeremystretch
9b51c2a0b2 Fix typo 2022-06-09 12:45:48 -04:00
jeremystretch
87b3be26a0 Closes #9434: Enabled django-rich test runner for more user-friendly output 2022-06-09 11:48:32 -04:00
jeremystretch
a74dba865c Merge branch 'develop' into feature 2022-06-09 11:38:21 -04:00
jeremystretch
180adb42a3 Use polylines to draw cable fanouts 2022-06-08 16:29:26 -04:00
jeremystretch
8d92ec2007 Add support for cable fan-outs 2022-06-07 16:43:22 -04:00
jeremystretch
ecba9699ed Introduce Node class for SVG boxes 2022-06-07 14:56:46 -04:00
jeremystretch
0b0a646f87 Clean up cable termination changes 2022-06-03 14:15:28 -04:00
jeremystretch
537383e071 Add cable topology tests 2022-06-02 12:40:31 -04:00
jeremystretch
bab6fb0de2 Update SVG trace rendering to support multiple terminations per cable end 2022-06-02 11:07:26 -04:00
jeremystretch
cf7a091319 Refactor SVG module 2022-06-02 08:28:27 -04:00
jeremystretch
3362bc3106 Remove link peer fields from cable termination models 2022-06-01 16:48:56 -04:00
jeremystretch
6befd2155a Clean up tests 2022-06-01 11:38:31 -04:00
jeremystretch
7b02070007 Merge branch 'feature' into 9102-cabling 2022-05-31 16:10:10 -04:00
jeremystretch
32322e95b6 Merge branch 'develop' into feature 2022-05-31 15:50:23 -04:00
jeremystretch
6280398bc1 Clean up tests 2022-05-26 16:40:20 -04:00
jeremystretch
1bd39e6568 Fix filtering cables by connected device/rack/site 2022-05-26 16:01:20 -04:00
jeremystretch
bd950e9ca6 Merge branch 'feature' into 9102-cabling 2022-05-26 15:37:29 -04:00
jeremystretch
db42589cca Closes #5303: A virtual machine may be assigned to a site and/or cluster 2022-05-26 14:59:49 -04:00
jeremystretch
b331f047af Closes #8222: Enable the assignment of a VM to a specific host device within a cluster 2022-05-25 16:01:10 -04:00
jeremystretch
31024ce672 Changelog & cleanup for #9166 2022-05-24 16:39:05 -04:00
Jeremy Stretch
aea023357b Merge pull request #9411 from kkthxbye-code/fix-9166-2
Fixes #9166 - Add the option to make custom fields read-only or hidden in UI
2022-05-24 16:14:23 -04:00
kkthxbye
6e7c5dcaed Remove whitespace from blank line 2022-05-24 10:38:55 +02:00
kkthxbye
c14a2a0a39 Exclude hidden custom fields from tables 2022-05-24 10:27:29 +02:00
kkthxbye
20eaa7d069 #9166 - Add UI Visibility setting for custom fields 2022-05-24 10:12:32 +02:00
jeremystretch
50c872c47c Clean up tests 2022-05-23 14:50:58 -04:00
jeremystretch
4ec26aa6aa Fix population of initial field selections when creating a cable 2022-05-23 13:52:39 -04:00
jeremystretch
a7d3e5e7f5 Merge all cable creation URLs into a single view 2022-05-23 13:22:45 -04:00
jeremystretch
7b5ff4c1a5 Merge cable creation/edit views & forms 2022-05-20 16:53:23 -04:00
jeremystretch
64146b8cb1 Closes #8471: Add status field to Cluster 2022-05-19 16:13:22 -04:00
jeremystretch
d155c39f59 Update cable termination type/ID filters 2022-05-18 16:48:49 -04:00
jeremystretch
a909ceda84 Simplify assignment of new cable terminations 2022-05-18 15:49:52 -04:00
jeremystretch
922916ae99 Improve cable termination panel layout 2022-05-18 12:56:16 -04:00
jeremystretch
75eea50d71 Order CableTerminations by cable, cable_end 2022-05-16 16:35:32 -04:00
jeremystretch
9ef9443969 Add REST API endpoint for CableTermination 2022-05-16 16:21:15 -04:00
jeremystretch
1505369133 Update Cable API serializer 2022-05-16 15:33:57 -04:00
jeremystretch
399afffddf Fix CablePath migration 2022-05-13 16:46:55 -04:00
jeremystretch
906c3dca8b Tests cleanup 2022-05-13 16:29:19 -04:00
jeremystretch
1b593384e5 Restore reverse relation to CableTerminations from Cable 2022-05-13 15:45:58 -04:00
jeremystretch
951627093c Test cleanup 2022-05-13 15:34:01 -04:00
jeremystretch
6ff2e55ce4 Add origins, destinations properties on CablePath 2022-05-13 15:05:38 -04:00
jeremystretch
3082c76ec6 Update object trace() method 2022-05-13 14:37:20 -04:00
jeremystretch
4c51dbba80 Update connected_endpoint serializer field to support multiple objects 2022-05-13 11:04:38 -04:00
jeremystretch
3a461d0279 Update Cable instantiations to match new signature 2022-05-13 09:33:00 -04:00
jeremystretch
594964aebe Simplify Cable init signature 2022-05-12 17:11:01 -04:00
jeremystretch
bb9eb119a4 Extend cable tracing tests 2022-05-12 16:08:44 -04:00
jeremystretch
4d5bcb65c8 Fix up pass-through port traversal 2022-05-12 16:08:44 -04:00
jeremystretch
c88f7b8408 Fix circuit termination tracing & update tests 2022-05-12 16:08:44 -04:00
jeremystretch
8bc6d8cb23 Introduce CablePath.retrace() to handle deleted cables 2022-05-12 16:08:44 -04:00
jeremystretch
eb4984afe6 Correct test queries 2022-05-12 16:08:44 -04:00
jeremystretch
d89f067c00 Update & restore rebuild_paths() 2022-05-12 16:08:44 -04:00
jeremystretch
5667a9c456 Refactor CablePath.from_origin() 2022-05-12 16:08:44 -04:00
jeremystretch
b44bfa1aa6 WIP 2022-05-12 16:08:44 -04:00
jeremystretch
304282bd4f Update tests 2022-05-12 16:08:44 -04:00
jeremystretch
93daa6406b Fix cable path tracing 2022-05-12 16:08:44 -04:00
jeremystretch
ecee7421ea Fix terminations assignment 2022-05-12 16:08:44 -04:00
jeremystretch
83fdab5feb Change CablePath.path to JSONField 2022-05-12 16:08:44 -04:00
jeremystretch
e129ae50f0 Start updating tests 2022-05-12 16:08:44 -04:00
jeremystretch
f0b722b0a5 Update cable tracing logic 2022-05-12 16:08:44 -04:00
jeremystretch
82706eb3a6 Migrate CablePath to use two-dimensional array 2022-05-12 16:08:44 -04:00
jeremystretch
c22007939b Ensure cable is cached on terminating endpoints 2022-05-12 16:08:44 -04:00
jeremystretch
f0fc8bf2cf Restrict A terminations by parent object 2022-05-12 16:08:44 -04:00
jeremystretch
c3742f63fd Update signal handlers (WIP) 2022-05-12 16:08:44 -04:00
jeremystretch
5d37f9f975 Update cable connection forms 2022-05-12 16:08:44 -04:00
jeremystretch
1f4ad444ae Introduce CableTermination model & migrate data 2022-05-12 16:08:44 -04:00
jeremystretch
907323d46f Revert initial model changes 2022-05-12 16:08:44 -04:00
jeremystretch
e0fc1b4f41 Remove errant print statement 2022-05-12 16:08:44 -04:00
jeremystretch
4bb9b6ee26 Extend Cable model to support multiple A/B terminations 2022-05-12 16:08:44 -04:00
jeremystretch
6c290353c1 Allow editing A side termination when creating cable 2022-05-12 16:08:44 -04:00
jeremystretch
c48c8cc353 Remove termination IDs from cable creation view paths 2022-05-12 16:08:44 -04:00
jeremystretch
e208404e3a Merge branch 'develop' into feature 2022-05-12 14:16:01 -04:00
jeremystretch
01d2ede097 Closes #1202: Support overlapping assignment of NAT IP addresses 2022-05-11 16:22:07 -04:00
jeremystretch
e2a02de6e9 Remove erroneous field from prefetch 2022-05-11 16:13:35 -04:00
jeremystretch
ca10a7834a Merge branch 'develop' into feature 2022-05-11 16:09:25 -04:00
jeremystretch
61d756c7c4 Closes #9261: NetBoxTable no longer automatically clears pre-existing calls to prefetch_related() on its queryset 2022-04-29 13:09:39 -04:00
jeremystretch
9ae25e9449 Merge branch 'develop' into feature 2022-04-29 09:05:52 -04:00
jeremystretch
e2b32757d8 Merge branch 'develop' into feature 2022-04-25 12:20:46 -04:00
Pieter Lambrecht
fa4807be8c Update releasenotes 2022-04-19 21:55:39 +02:00
Pieter Lambrecht
086e34f728 Updated docs relnotes to refer to 8233 2022-04-19 21:33:29 +02:00
Pieter Lambrecht
2587720298 Fix 8878: Restrict API key usage by Source IP 2022-04-19 14:44:35 +02:00
Jeremy Stretch
098ef91583 Merge pull request #9144 from netbox-community/8495-custom-field-groups
Closes #8495: Enable custom field grouping
2022-04-15 14:57:28 -04:00
jeremystretch
17df8a5c43 Closes #8495: Enable custom field grouping 2022-04-15 14:45:28 -04:00
jeremystretch
4fac10ac4a Merge branch 'develop' into feature 2022-04-15 12:09:28 -04:00
jeremystretch
03535ce50b Closes #8995: Enable arbitrary ordering of REST API results 2022-04-08 16:00:33 -04:00
320 changed files with 9546 additions and 4208 deletions

View File

@@ -14,7 +14,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v3.2.7
placeholder: v3.3-beta2
validations:
required: true
- type: dropdown

View File

@@ -14,7 +14,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v3.2.7
placeholder: v3.3-beta2
validations:
required: true
- type: dropdown

View File

@@ -34,10 +34,14 @@ django-pglocks
# https://github.com/korfuri/django-prometheus
django-prometheus
# Django chaching backend using Redis
# Django caching backend using Redis
# https://github.com/jazzband/django-redis
django-redis
# Django extensions for Rich (terminal text rendering)
# https://github.com/adamchainz/django-rich
django-rich
# Django integration for RQ (Reqis queuing)
# https://github.com/rq/django-rq
django-rq
@@ -48,8 +52,7 @@ django-tables2
# User-defined tags for objects
# https://github.com/alex/django-taggit
# Will evaluate v3.0 during NetBox v3.3 beta
django-taggit>=2.1.0,<3.0
django-taggit
# A Django field for representing time zones
# https://github.com/mfogel/django-timezone-field/

View File

@@ -4,7 +4,7 @@ NetBox v2.9 introduced a new object-based permissions framework, which replaces
{!models/users/objectpermission.md!}
### Example Constraint Definitions
#### Example Constraint Definitions
| Constraints | Description |
| ----------- | ----------- |

View File

@@ -212,6 +212,7 @@ The following model fields support configurable choices:
* `circuits.Circuit.status`
* `dcim.Device.status`
* `dcim.Location.status`
* `dcim.PowerFeed.status`
* `dcim.Rack.status`
* `dcim.Site.status`
@@ -220,6 +221,7 @@ The following model fields support configurable choices:
* `ipam.IPRange.status`
* `ipam.Prefix.status`
* `ipam.VLAN.status`
* `virtualization.Cluster.status`
* `virtualization.VirtualMachine.status`
The following colors are supported:

View File

@@ -26,3 +26,8 @@
---
{!models/ipam/asn.md!}
---
{!models/ipam/l2vpn.md!}
{!models/ipam/l2vpntermination.md!}

View File

@@ -45,6 +45,8 @@ The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/
* [ipam.FHRPGroup](../models/ipam/fhrpgroup.md)
* [ipam.IPAddress](../models/ipam/ipaddress.md)
* [ipam.IPRange](../models/ipam/iprange.md)
* [ipam.L2VPN](../models/ipam/l2vpn.md)
* [ipam.L2VPNTermination](../models/ipam/l2vpntermination.md)
* [ipam.Prefix](../models/ipam/prefix.md)
* [ipam.RouteTarget](../models/ipam/routetarget.md)
* [ipam.Service](../models/ipam/service.md)

View File

@@ -13,7 +13,7 @@ Each circuit is also assigned one of the following operational statuses:
* Deprovisioning
* Decommissioned
Circuits also have optional fields for annotating their installation date and commit rate, and may be assigned to NetBox tenants.
Circuits also have optional fields for annotating their installation and termination dates and commit rate, and may be assigned to NetBox tenants.
!!! note
NetBox currently models only physical circuits: those which have exactly two endpoints. It is common to layer virtualized constructs (_virtual circuits_) such as MPLS or EVPN tunnels on top of these, however NetBox does not yet support virtual circuit modeling.

View File

@@ -11,6 +11,13 @@ Interfaces may be physical or virtual in nature, but only physical interfaces ma
Physical interfaces may be arranged into a link aggregation group (LAG) and associated with a parent LAG (virtual) interface. LAG interfaces can be recursively nested to model bonding of trunk groups. Like all virtual interfaces, LAG interfaces cannot be connected physically.
### Power over Ethernet (PoE)
!!! note
This feature was added in NetBox v3.3.
Physical interfaces can be assigned a PoE mode to indicate PoE capability: power supplying equipment (PSE) or powered device (PD). Additionally, a PoE mode may be specified. This can be one of the listed IEEE 802.3 standards, or a passive setting (24 or 48 volts across two or four pairs).
### Wireless Interfaces
Wireless interfaces may additionally track the following attributes:

View File

@@ -1,3 +1,3 @@
## Interface Templates
A template for a network interface that will be created on all instantiations of the parent device type. Each interface may be assigned a physical or virtual type, and may be designated as "management-only."
A template for a network interface that will be created on all instantiations of the parent device type. Each interface may be assigned a physical or virtual type, and may be designated as "management-only." Power over Ethernet (PoE) mode and type may also be assigned to interface templates.

View File

@@ -2,5 +2,4 @@
Racks and devices can be grouped by location within a site. A location may represent a floor, room, cage, or similar organizational unit. Locations can be nested to form a hierarchy. For example, you may have floors within a site, and rooms within a floor.
Each location must have a name that is unique within its parent site and location, if any.
Each location must have a name that is unique within its parent site and location, if any, and must be assigned an operational status. (The set of available statuses is configurable.)

View File

@@ -5,9 +5,11 @@ Sometimes it is desirable to associate additional data with a group of devices o
* Region
* Site group
* Site
* Location (devices only)
* Device type (devices only)
* Role
* Platform
* Cluster type (VMs only)
* Cluster group (VMs only)
* Cluster (VMs only)
* Tenant group

View File

@@ -26,11 +26,35 @@ Each custom field must have a name. This should be a simple database-friendly st
Marking a field as required will force the user to provide a value for the field when creating a new object or when saving an existing object. A default value for the field may also be provided. Use "true" or "false" for boolean fields, or the exact value of a choice for selection fields.
The filter logic controls how values are matched when filtering objects by the custom field. Loose filtering (the default) matches on a partial value, whereas exact matching requires a complete match of the given string to a field's value. For example, exact filtering with the string "red" will only match the exact value "red", whereas loose filtering will match on the values "red", "red-orange", or "bored". Setting the filter logic to "disabled" disables filtering by the field entirely.
A custom field must be assigned to one or more object types, or models, in NetBox. Once created, custom fields will automatically appear as part of these models in the web UI and REST API. Note that not all models support custom fields.
### Custom Field Validation
### Filtering
The filter logic controls how values are matched when filtering objects by the custom field. Loose filtering (the default) matches on a partial value, whereas exact matching requires a complete match of the given string to a field's value. For example, exact filtering with the string "red" will only match the exact value "red", whereas loose filtering will match on the values "red", "red-orange", or "bored". Setting the filter logic to "disabled" disables filtering by the field entirely.
### Grouping
!!! note
This feature was introduced in NetBox v3.3.
Related custom fields can be grouped together within the UI by assigning each the same group name. When at least one custom field for an object type has a group defined, it will appear under the group heading within the custom fields panel under the object view. All custom fields with the same group name will appear under that heading. (Note that the group names must match exactly, or each will appear as a separate heading.)
This parameter has no effect on the API representation of custom field data.
### Visibility
!!! note
This feature was introduced in NetBox v3.3.
When creating a custom field, there are three options for UI visibility. These control how and whether the custom field is displayed within the NetBox UI.
* **Read/write** (default): The custom field is included when viewing and editing objects.
* **Read-only**: The custom field is displayed when viewing an object, but it cannot be edited via the UI. (It will appear in the form as a read-only field.)
* **Hidden**: The custom field will never be displayed within the UI. This option is recommended for fields which are not intended for use by human users.
Note that this setting has no impact on the REST or GraphQL APIs: Custom field data will always be available via either API.
### Validation
NetBox supports limited custom validation for custom field values. Following are the types of validation enforced for each field type:

21
docs/models/ipam/l2vpn.md Normal file
View File

@@ -0,0 +1,21 @@
# L2VPN
A L2VPN object is NetBox is a representation of a layer 2 bridge technology such as VXLAN, VPLS or EPL. Each L2VPN can be identified by name as well as an optional unique identifier (VNI would be an example).
Each L2VPN instance must have one of the following type associated with it:
* VPLS
* VPWS
* EPL
* EVPL
* EP-LAN
* EVP-LAN
* EP-TREE
* EVP-TREE
* VXLAN
* VXLAN EVPN
* MPLS-EVPN
* PBB-EVPN
!!! note
Choosing VPWS, EPL, EP-LAN, EP-TREE will result in only being able to add two terminations to a given L2VPN.

View File

@@ -0,0 +1,15 @@
# L2VPN Termination
A L2VPN Termination is the termination point of a L2VPN. Certain types of L2VPNs may only have 2 termination points (point-to-point) while others may have many terminations (multipoint).
Each termination consists of a L2VPN it is a member of as well as the connected endpoint which can be an interface or a VLAN.
The following types of L2VPNs are considered point-to-point:
* VPWS
* EPL
* EP-LAN
* EP-TREE
!!! note
Choosing any of the above types will result in only being able to add 2 terminations to a given L2VPN.

View File

@@ -53,3 +53,17 @@ To achieve a logical OR with a different set of constraints, define multiple obj
```
Additionally, where multiple permissions have been assigned for an object type, their collective constraints will be merged using a logical "OR" operation.
### User Token
!!! info "This feature was introduced in NetBox v3.3"
When defining a permission constraint, administrators may use the special token `$user` to reference the current user at the time of evaluation. This can be helpful to restrict users to editing only their own journal entries, for example. Such a constraint might be defined as:
```json
{
"created_by": "$user"
}
```
The `$user` token can be used only as a constraint value, or as an item within a list of values. It cannot be modified or extended to reference specific user attributes.

View File

@@ -10,3 +10,10 @@ Each token contains a 160-bit key represented as 40 hexadecimal characters. When
By default, a token can be used to perform all actions via the API that a user would be permitted to do via the web UI. Deselecting the "write enabled" option will restrict API requests made with the token to read operations (e.g. GET) only.
Additionally, a token can be set to expire at a specific time. This can be useful if an external client needs to be granted temporary access to NetBox.
### Client IP Restriction
!!! note
This feature was introduced in NetBox v3.3.
Each API token can optionally be restricted by client IP address. If one or more allowed IP prefixes/addresses is defined for a token, authentication will fail for any client connecting from an IP address outside the defined range(s). This enables restricting the use a token to a specific client. (By default, any client IP address is permitted.)

View File

@@ -1,5 +1,5 @@
# Clusters
A cluster is a logical grouping of physical resources within which virtual machines run. A cluster must be assigned a type (technological classification), and may optionally be assigned to a cluster group, site, and/or tenant. Each cluster must have a unique name within its assigned group and/or site, if any.
A cluster is a logical grouping of physical resources within which virtual machines run. A cluster must be assigned a type (technological classification) and operational status, and may optionally be assigned to a cluster group, site, and/or tenant. Each cluster must have a unique name within its assigned group and/or site, if any.
Physical devices may be associated with clusters as hosts. This allows users to track on which host(s) a particular virtual machine may reside. However, NetBox does not support pinning a specific VM within a cluster to a particular host device.

View File

@@ -1,6 +1,6 @@
# Virtual Machines
A virtual machine represents a virtual compute instance hosted within a cluster. Each VM must be assigned to exactly one cluster.
A virtual machine represents a virtual compute instance hosted within a cluster. Each VM must be assigned to a site and/or cluster, and may optionally be assigned to a particular host device within a cluster.
Like devices, each VM can be assigned a platform and/or functional role, and must have one of the following operational statuses assigned to it:

View File

@@ -1,6 +1,6 @@
# Wireless LANs
A wireless LAN is a set of interfaces connected via a common wireless channel. Each instance must have an SSID, and may optionally be correlated to a VLAN. Wireless LANs can be arranged into hierarchical groups.
A wireless LAN is a set of interfaces connected via a common wireless channel. Each instance must have an SSID, and may optionally be correlated to a VLAN. Wireless LANs can be arranged into hierarchical groups, and each may be associated with a particular tenant.
An interface may be attached to multiple wireless LANs, provided they are all operating on the same channel. Only wireless interfaces may be attached to wireless LANs.

View File

@@ -1,6 +1,6 @@
# Wireless Links
A wireless link represents a connection between exactly two wireless interfaces. It may optionally be assigned an SSID and a description. It may also have a status assigned to it, similar to the cable model.
A wireless link represents a connection between exactly two wireless interfaces. It may optionally be assigned an SSID and a description. It may also have a status assigned to it, similar to the cable model. Each wireless link may also be assigned to a particular tenant.
Each wireless link may have authentication attributes associated with it, including:

View File

@@ -0,0 +1,28 @@
# Exceptions
The exception classes listed here may be raised by a plugin to alter NetBox's default behavior in various scenarios.
## `AbortRequest`
NetBox provides several [generic views](./views.md) and [REST API viewsets](./rest-api.md) which facilitate the creation, modification, and deletion of objects, either individually or in bulk. Under certain conditions, it may be desirable for a plugin to interrupt these actions and cleanly abort the request, reporting an error message to the end user or API consumer.
For example, a plugin may prohibit the creation of a site with a prohibited name by connecting a receiver to Django's `pre_save` signal for the Site model:
```python
from django.db.models.signals import pre_save
from django.dispatch import receiver
from dcim.models import Site
from utilities.exceptions import AbortRequest
PROHIBITED_NAMES = ('foo', 'bar', 'baz')
@receiver(pre_save, sender=Site)
def test_abort_request(instance, **kwargs):
if instance.name.lower() in PROHIBITED_NAMES:
raise AbortRequest(f"Site name can't be {instance.name}!")
```
An error message must be supplied when raising `AbortRequest`. This will be conveyed to the user and should clearly explain the reason for which the request was aborted, as well as any potential remedy.
!!! tip "Consider custom validation rules"
This exception is intended to be used for handling complex evaluation logic and should be used sparingly. For simple object validation (such as the contrived example above), consider using [custom validation rules](../../customization/custom-validation.md) instead.

View File

@@ -37,7 +37,7 @@ This class performs two crucial functions:
1. Apply any fields, methods, and/or attributes necessary to the operation of these features
2. Register the model with NetBox as utilizing these features
Simply subclass BaseModel when defining a model in your plugin:
Simply subclass NetBoxModel when defining a model in your plugin:
```python
# models.py
@@ -49,6 +49,24 @@ class MyModel(NetBoxModel):
...
```
### The `clone()` Method
!!! info
This method was introduced in NetBox v3.3.
The `NetBoxModel` class includes a `clone()` method to be used for gathering attributes which can be used to create a "cloned" instance. This is used primarily for form initialization, e.g. when using the "clone" button in the NetBox UI. By default, this method will replicate any fields listed in the model's `clone_fields` list, if defined.
Plugin models can leverage this method by defining `clone_fields` as a list of field names to be replicated, or override this method to replace or extend its content:
```python
class MyModel(NetBoxModel):
def clone(self):
attrs = super().clone()
attrs['extra-value'] = 123
return attrs
```
### Enabling Features Individually
If you prefer instead to enable only a subset of these features for a plugin model, NetBox provides a discrete "mix-in" class for each feature. You can subclass each of these individually when defining your model. (Your model will also need to inherit from Django's built-in `Model` class.)

View File

@@ -215,6 +215,8 @@ The following custom template tags are available in NetBox.
::: utilities.templatetags.builtins.tags.checkmark
::: utilities.templatetags.builtins.tags.customfield_value
::: utilities.templatetags.builtins.tags.tag
## Filters

View File

@@ -51,15 +51,16 @@ This makes our view accessible at the URL `/plugins/animal-sounds/random/`. (Rem
NetBox provides several generic view classes (documented below) to facilitate common operations, such as creating, viewing, modifying, and deleting objects. Plugins can subclass these views for their own use.
| View Class | Description |
|--------------------|--------------------------------|
| `ObjectView` | View a single object |
| `ObjectEditView` | Create or edit a single object |
| `ObjectDeleteView` | Delete a single object |
| `ObjectListView` | View a list of objects |
| `BulkImportView` | Import a set of new objects |
| `BulkEditView` | Edit multiple objects |
| `BulkDeleteView` | Delete multiple objects |
| View Class | Description |
|----------------------|--------------------------------------------------------|
| `ObjectView` | View a single object |
| `ObjectEditView` | Create or edit a single object |
| `ObjectDeleteView` | Delete a single object |
| `ObjectChildrenView` | A list of child objects within the context of a parent |
| `ObjectListView` | View a list of objects |
| `BulkImportView` | Import a set of new objects |
| `BulkEditView` | Edit multiple objects |
| `BulkDeleteView` | Delete multiple objects |
!!! warning
Please note that only the classes which appear in this documentation are currently supported. Although other classes may be present within the `views.generic` module, they are not yet supported for use by plugins.
@@ -99,6 +100,12 @@ Below are the class definitions for NetBox's object views. These views handle CR
members:
- get_object
::: netbox.views.generic.ObjectChildrenView
selection:
members:
- get_children
- prep_table_data
## Multi-Object Views
Below are the class definitions for NetBox's multi-object views. These views handle simultaneous actions for sets objects. The list, import, edit, and delete views each inherit from `BaseMultiObjectView`, which is not intended to be used directly.

View File

@@ -10,6 +10,17 @@ Minor releases are published in April, August, and December of each calendar yea
This page contains a history of all major and minor releases since NetBox v2.0. For more detail on a specific patch release, please see the release notes page for that specific minor release.
#### [Version 3.3](./version-3.3.md) (August 2022)
* Multi-object Cable Terminations ([#9102](https://github.com/netbox-community/netbox/issues/9102))
* L2VPN Modeling ([#8157](https://github.com/netbox-community/netbox/issues/8157))
* PoE Interface Attributes ([#1099](https://github.com/netbox-community/netbox/issues/1099))
* Half-Height Rack Units ([#51](https://github.com/netbox-community/netbox/issues/51))
* Restrict API Tokens by Client IP ([#8233](https://github.com/netbox-community/netbox/issues/8233))
* Reference User in Permission Constraints ([#9074](https://github.com/netbox-community/netbox/issues/9074))
* Custom Field Grouping ([#8495](https://github.com/netbox-community/netbox/issues/8495))
* Toggle Custom Field Visibility ([#9166](https://github.com/netbox-community/netbox/issues/9166))
#### [Version 3.2](./version-3.2.md) (April 2022)
* Plugins Framework Extensions ([#8333](https://github.com/netbox-community/netbox/issues/8333))

View File

@@ -1,5 +1,26 @@
# NetBox v3.2
## v3.2.8 (FUTURE)
### Enhancements
* [#9062](https://github.com/netbox-community/netbox/issues/9062) - Add/edit {module} substitution to help text for component template name
* [#9637](https://github.com/netbox-community/netbox/issues/9637) - Add site group field to rack reservation form
* [#9762](https://github.com/netbox-community/netbox/issues/9762) - Add `nat_outside` column to the IPAddress table
* [#9825](https://github.com/netbox-community/netbox/issues/9825) - Add contacts column to virtual machines table
* [#9881](https://github.com/netbox-community/netbox/issues/9881) - Increase granularity in utilization graph values
* [#9882](https://github.com/netbox-community/netbox/issues/9882) - Add manufacturer column to modules table
* [#9883](https://github.com/netbox-community/netbox/issues/9883) - Linkify location column in power panels table
### Bug Fixes
* [#9871](https://github.com/netbox-community/netbox/issues/9871) - Fix utilization graph value alignments
* [#9884](https://github.com/netbox-community/netbox/issues/9884) - Prevent querying assigned VRF on prefix object init
* [#9885](https://github.com/netbox-community/netbox/issues/9885) - Fix child prefix counts when editing/deleting aggregates in bulk
* [#9891](https://github.com/netbox-community/netbox/issues/9891) - Ensure consistent ordering for tags during object serialization
---
## v3.2.7 (2022-07-20)
### Enhancements

View File

@@ -0,0 +1,236 @@
# NetBox v3.3
## v3.3-beta2 (2022-08-03)
### Breaking Changes
* Device position and rack unit values are now reported as decimals (e.g. `1.0` or `1.5`) to support modeling half-height rack units.
* The `nat_outside` relation on the IP address model now returns a list of zero or more related IP addresses, rather than a single instance (or None).
* Several fields on the cable API serializers have been altered or removed to support multiple-object cable terminations:
| Old Name | Old Type | New Name | New Type |
|----------------------|----------|-----------------------|----------|
| `termination_a_type` | string | _Removed_ | - |
| `termination_b_type` | string | _Removed_ | - |
| `termination_a_id` | integer | _Removed_ | - |
| `termination_b_id` | integer | _Removed_ | - |
| `termination_a` | object | `a_terminations` | list |
| `termination_b` | object | `b_terminations` | list |
* As with the cable model, several API fields on all objects to which cables can be connected (interfaces, circuit terminations, etc.) have been changed:
| Old Name | Old Type | New Name | New Type |
|--------------------------------|----------|---------------------------------|----------|
| `link_peer` | object | `link_peers` | list |
| `link_peer_type` | string | `link_peers_type` | string |
| `connected_endpoint` | object | `connected_endpoints` | list |
| `connected_endpoint_type` | string | `connected_endpoints_type` | string |
| `connected_endpoint_reachable` | boolean | `connected_endpoints_reachable` | boolean |
* The cable path serialization returned by the `/paths/` endpoint for pass-through ports has been simplified, and the following fields removed: `origin_type`, `origin`, `destination_type`, `destination`. (Additionally, `is_complete` has been added.)
### New Features
#### Multi-object Cable Terminations ([#9102](https://github.com/netbox-community/netbox/issues/9102))
When creating a cable in NetBox, each end can now be attached to multiple objects. This allows accurate modeling of duplex fiber connections to individual termination ports and breakout cables, as examples. (Note that all terminations attached to one end of a cable must be the same object type, but do not need to connect to the same parent object.) Additionally, cable terminations can now be modified without needing to delete and recreate the cable.
#### L2VPN Modeling ([#8157](https://github.com/netbox-community/netbox/issues/8157))
NetBox can now model a variety of L2 VPN technologies, including VXLAN, VPLS, and others. Each L2VPN can be terminated to multiple device or virtual machine interfaces and/or VLANs to track connectivity across an overlay. Similarly to VRFs, each L2VPN can also have import and export route targets associated with it.
#### PoE Interface Attributes ([#1099](https://github.com/netbox-community/netbox/issues/1099))
Two new fields have been added to the device interface model to track power over Ethernet (PoE) capabilities:
* **PoE mode**: Power supplying equipment (PSE) or powered device (PD)
* **PoE type**: Applicable IEEE standard or other power type
#### Half-Height Rack Units ([#51](https://github.com/netbox-community/netbox/issues/51))
Device type height can now be specified in 0.5U increments, allowing for the creation of half-height devices. Additionally, a device can be installed at the half-unit mark within a rack (e.g. U2.5). For example, two half-height devices positioned in sequence will consume a single rack unit; two consecutive 1.5U devices will consume 3U of space.
#### Restrict API Tokens by Client IP ([#8233](https://github.com/netbox-community/netbox/issues/8233))
API tokens can now be restricted to use by certain client IP addresses or networks. For example, an API token with its `allowed_ips` list set to `[192.0.2.0/24]` will only permit authentication from API clients within that network; requests from other sources will fail authentication. This can be very useful for restricting the use of a token to specific clients.
#### Reference User in Permission Constraints ([#9074](https://github.com/netbox-community/netbox/issues/9074))
NetBox's permission constraints have been expanded to support referencing the current user associated with a request using the special `$user` token. As an example, this enables an administrator to efficiently grant each user to edit his or her own journal entries, but not those created by other users.
```json
{
"created_by": "$user"
}
```
#### Custom Field Grouping ([#8495](https://github.com/netbox-community/netbox/issues/8495))
A `group_name` field has been added to the custom field model to enable organizing related custom fields by group. Similarly to custom links, custom links which have been assigned to a common group will be rendered within that group when viewing an object in the UI. (Custom field grouping has no effect on API operation.)
#### Toggle Custom Field Visibility ([#9166](https://github.com/netbox-community/netbox/issues/9166))
The behavior of each custom field within the NetBox UI can now be controlled individually by toggling its UI visibility. Three settings are available:
* **Read/write**: The custom field is included when viewing and editing objects (default).
* **Read-only**: The custom field is displayed when viewing an object, but it cannot be edited via the UI. (It will appear in the form as a read-only field.)
* **Hidden**: The custom field will never be displayed within the UI. This option is recommended for fields which are not intended for use by human users.
Custom field UI visibility has no impact on API operation.
### Enhancements
* [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses
* [#4350](https://github.com/netbox-community/netbox/issues/4350) - Illustrate reservations vertically alongside rack elevations
* [#4434](https://github.com/netbox-community/netbox/issues/4434) - Enable highlighting devices within rack elevations
* [#5303](https://github.com/netbox-community/netbox/issues/5303) - A virtual machine may be assigned to a site and/or cluster
* [#7120](https://github.com/netbox-community/netbox/issues/7120) - Add `termination_date` field to Circuit
* [#7744](https://github.com/netbox-community/netbox/issues/7744) - Add `status` field to Location
* [#8171](https://github.com/netbox-community/netbox/issues/8171) - Populate next available address when cloning an IP
* [#8222](https://github.com/netbox-community/netbox/issues/8222) - Enable the assignment of a VM to a specific host device within a cluster
* [#8471](https://github.com/netbox-community/netbox/issues/8471) - Add `status` field to Cluster
* [#8511](https://github.com/netbox-community/netbox/issues/8511) - Enable custom fields and tags for circuit terminations
* [#8995](https://github.com/netbox-community/netbox/issues/8995) - Enable arbitrary ordering of REST API results
* [#9070](https://github.com/netbox-community/netbox/issues/9070) - Hide navigation menu items based on user permissions
* [#9177](https://github.com/netbox-community/netbox/issues/9177) - Add tenant assignment for wireless LANs & links
* [#9391](https://github.com/netbox-community/netbox/issues/9391) - Remove 500-character limit for custom link text & URL fields
* [#9536](https://github.com/netbox-community/netbox/issues/9536) - Track API token usage times
* [#9582](https://github.com/netbox-community/netbox/issues/9582) - Enable assigning config contexts based on device location
### Bug Fixes (from Beta1)
* [#9728](https://github.com/netbox-community/netbox/issues/9728) - Fix validation when assigning a virtual machine to a device
* [#9729](https://github.com/netbox-community/netbox/issues/9729) - Fix ordering of content type creation to ensure compatability with demo data
* [#9730](https://github.com/netbox-community/netbox/issues/9730) - Fix validation error when creating a new cable via UI form
* [#9733](https://github.com/netbox-community/netbox/issues/9733) - Handle split paths during trace when fanning out to front ports with differing cables
* [#9765](https://github.com/netbox-community/netbox/issues/9765) - Report correct segment count under cable trace UI view
* [#9778](https://github.com/netbox-community/netbox/issues/9778) - Fix exception during cable deletion after deleting a connected termination
* [#9788](https://github.com/netbox-community/netbox/issues/9788) - Ensure denormalized fields on CableTermination are kept in sync with related objects
* [#9789](https://github.com/netbox-community/netbox/issues/9789) - Fix rendering of cable traces ending at provider networks
* [#9794](https://github.com/netbox-community/netbox/issues/9794) - Fix link to connect a rear port to a circuit termination
* [#9818](https://github.com/netbox-community/netbox/issues/9818) - Fix circuit side selection when connecting a cable to a circuit termination
* [#9829](https://github.com/netbox-community/netbox/issues/9829) - Arrange custom fields by group when editing objects
* [#9843](https://github.com/netbox-community/netbox/issues/9843) - Fix rendering of custom field values (regression from #9647)
* [#9844](https://github.com/netbox-community/netbox/issues/9844) - Fix interface api request when creating/editing L2VPN termination
* [#9847](https://github.com/netbox-community/netbox/issues/9847) - Respect `desc_units` when ordering rack units
### Plugins API
* [#9075](https://github.com/netbox-community/netbox/issues/9075) - Introduce `AbortRequest` exception for cleanly interrupting object mutations
* [#9092](https://github.com/netbox-community/netbox/issues/9092) - Add support for `ObjectChildrenView` generic view
* [#9228](https://github.com/netbox-community/netbox/issues/9228) - Subclasses of `ChangeLoggingMixin` can override `serialize_object()` to control JSON serialization for change logging
* [#9414](https://github.com/netbox-community/netbox/issues/9414) - Add `clone()` method to NetBoxModel for copying instance attributes
* [#9647](https://github.com/netbox-community/netbox/issues/9647) - Introduce `customfield_value` template tag
### Other Changes
* [#9261](https://github.com/netbox-community/netbox/issues/9261) - `NetBoxTable` no longer automatically clears pre-existing calls to `prefetch_related()` on its queryset
* [#9434](https://github.com/netbox-community/netbox/issues/9434) - Enabled `django-rich` test runner for more user-friendly output
* [#9903](https://github.com/netbox-community/netbox/issues/9903) - Implement a mechanism for automatically updating denormalized fields
### REST API Changes
* List results can now be ordered by field, by appending `?ordering={fieldname}` to the query. Multiple fields can be specified by separating the field names with a comma, e.g. `?ordering=site,name`. To invert the ordering, prepend a hyphen to the field name, e.g. `?ordering=-name`.
* Added the following endpoints:
* `/api/dcim/cable-terminations/`
* `/api/ipam/l2vpns/`
* `/api/ipam/l2vpn-terminations/`
* circuits.Circuit
* Added optional `termination_date` field
* circuits.CircuitTermination
* `link_peer` has been renamed to `link_peers` and now returns a list of objects
* `link_peer_type` has been renamed to `link_peers_type`
* `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects
* `connected_endpoint_type` has been renamed to `connected_endpoints_type`
* `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable`
* Added `custom_fields` and `tags` fields
* dcim.Cable
* `termination_a_type` has been renamed to `a_terminations_type`
* `termination_b_type` has been renamed to `b_terminations_type`
* `termination_a` renamed to `a_terminations` and now returns a list of objects
* `termination_b` renamed to `b_terminations` and now returns a list of objects
* `termination_a_id` has been removed
* `termination_b_id` has been removed
* dcim.ConsolePort
* `link_peer` has been renamed to `link_peers` and now returns a list of objects
* `link_peer_type` has been renamed to `link_peers_type`
* `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects
* `connected_endpoint_type` has been renamed to `connected_endpoints_type`
* `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable`
* dcim.ConsoleServerPort
* `link_peer` has been renamed to `link_peers` and now returns a list of objects
* `link_peer_type` has been renamed to `link_peers_type`
* `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects
* `connected_endpoint_type` has been renamed to `connected_endpoints_type`
* `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable`
* dcim.Device
* The `position` field has been changed from an integer to a decimal
* dcim.DeviceType
* The `u_height` field has been changed from an integer to a decimal
* dcim.FrontPort
* `link_peer` has been renamed to `link_peers` and now returns a list of objects
* `link_peer_type` has been renamed to `link_peers_type`
* `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects
* `connected_endpoint_type` has been renamed to `connected_endpoints_type`
* `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable`
* dcim.Interface
* `link_peer` has been renamed to `link_peers` and now returns a list of objects
* `link_peer_type` has been renamed to `link_peers_type`
* `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects
* `connected_endpoint_type` has been renamed to `connected_endpoints_type`
* `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable`
* Added the optional `poe_mode` and `poe_type` fields
* Added the `l2vpn_termination` read-only field
* dcim.InterfaceTemplate
* Added the optional `poe_mode` and `poe_type` fields
* dcim.Location
* Added required `status` field (default value: `active`)
* dcim.PowerOutlet
* `link_peer` has been renamed to `link_peers` and now returns a list of objects
* `link_peer_type` has been renamed to `link_peers_type`
* `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects
* `connected_endpoint_type` has been renamed to `connected_endpoints_type`
* `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable`
* dcim.PowerFeed
* `link_peer` has been renamed to `link_peers` and now returns a list of objects
* `link_peer_type` has been renamed to `link_peers_type`
* `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects
* `connected_endpoint_type` has been renamed to `connected_endpoints_type`
* `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable`
* dcim.PowerPort
* `link_peer` has been renamed to `link_peers` and now returns a list of objects
* `link_peer_type` has been renamed to `link_peers_type`
* `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects
* `connected_endpoint_type` has been renamed to `connected_endpoints_type`
* `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable`
* dcim.Rack
* The `elevation` endpoint now includes half-height rack units, and utilizes decimal values for the ID and name of each unit
* dcim.RearPort
* `link_peer` has been renamed to `link_peers` and now returns a list of objects
* `link_peer_type` has been renamed to `link_peers_type`
* `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects
* `connected_endpoint_type` has been renamed to `connected_endpoints_type`
* `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable`
* extras.ConfigContext
* Added the `locations` many-to-many field to track the assignment of ConfigContexts to Locations
* extras.CustomField
* Added `group_name` and `ui_visibility` fields
* ipam.IPAddress
* The `nat_inside` field no longer requires a unique value
* The `nat_outside` field has changed from a single IP address instance to a list of multiple IP addresses
* ipam.VLAN
* Added the `l2vpn_termination` read-only field
* users.Token
* Added the `allowed_ips` array field
* Added the read-only `last_used` datetime field
* virtualization.Cluster
* Added required `status` field (default value: `active`)
* virtualization.VirtualMachine
* The `site` field is now directly writable (rather than being inferred from the assigned cluster)
* The `cluster` field is now optional. A virtual machine must have a site and/or cluster assigned.
* Added the optional `device` field
* Added the `l2vpn_termination` read-only field
wireless.WirelessLAN
* Added `tenant` field
wireless.WirelessLink
* Added `tenant` field

View File

@@ -29,6 +29,11 @@ $ curl https://netbox/api/dcim/sites/
}
```
When a token is used to authenticate a request, its `last_updated` time updated to the current time if its last use was recorded more than 60 seconds ago (or was never recorded). This allows users to determine which tokens have been active recently.
!!! note
The "last used" time for tokens will not be updated while maintenance mode is enabled.
## Initial Token Provisioning
Ideally, each user should provision his or her own REST API token(s) via the web UI. However, you may encounter where a token must be created by a user via the REST API itself. NetBox provides a special endpoint to provision tokens using a valid username and password combination.

View File

@@ -106,3 +106,23 @@ expression: `n`. Here is an example of a lookup expression on a foreign key, it
```no-highlight
GET /api/ipam/vlans/?group_id__n=3203
```
## Ordering Objects
To order results by a particular field, include the `ordering` query parameter. For example, order the list of sites according to their facility values:
```no-highlight
GET /api/dcim/sites/?ordering=facility
```
To invert the ordering, prepend a hyphen to the field name:
```no-highlight
GET /api/dcim/sites/?ordering=-facility
```
Multiple fields can be specified by separating the field names with a comma. For example:
```no-highlight
GET /api/dcim/sites/?ordering=facility,-name
```

View File

@@ -118,6 +118,7 @@ nav:
- REST API: 'plugins/development/rest-api.md'
- GraphQL API: 'plugins/development/graphql-api.md'
- Background Tasks: 'plugins/development/background-tasks.md'
- Exceptions: 'plugins/development/exceptions.md'
- Administration:
- Authentication:
- Overview: 'administration/authentication/overview.md'
@@ -130,7 +131,7 @@ nav:
- NetBox Shell: 'administration/netbox-shell.md'
- REST API:
- Overview: 'rest-api/overview.md'
- Filtering: 'rest-api/filtering.md'
- Filtering & Ordering: 'rest-api/filtering.md'
- Authentication: 'rest-api/authentication.md'
- GraphQL API:
- Overview: 'graphql-api/overview.md'
@@ -151,6 +152,7 @@ nav:
- Release Checklist: 'development/release-checklist.md'
- Release Notes:
- Summary: 'release-notes/index.md'
- Version 3.3: 'release-notes/version-3.3.md'
- Version 3.2: 'release-notes/version-3.2.md'
- Version 3.1: 'release-notes/version-3.1.md'
- Version 3.0: 'release-notes/version-3.0.md'

View File

@@ -1,7 +1,7 @@
from rest_framework import serializers
from circuits.models import *
from netbox.api import WritableNestedSerializer
from netbox.api.serializers import WritableNestedSerializer
__all__ = [
'NestedCircuitSerializer',

View File

@@ -2,12 +2,12 @@ from rest_framework import serializers
from circuits.choices import CircuitStatusChoices
from circuits.models import *
from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer
from dcim.api.serializers import LinkTerminationSerializer
from dcim.api.nested_serializers import NestedSiteSerializer
from dcim.api.serializers import CabledObjectSerializer
from ipam.models import ASN
from ipam.api.nested_serializers import NestedASNSerializer
from netbox.api import ChoiceField, SerializedPKRelatedField
from netbox.api.serializers import NetBoxModelSerializer, ValidatedModelSerializer, WritableNestedSerializer
from netbox.api.fields import ChoiceField, SerializedPKRelatedField
from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer
from tenancy.api.nested_serializers import NestedTenantSerializer
from .nested_serializers import *
@@ -92,23 +92,22 @@ class CircuitSerializer(NetBoxModelSerializer):
class Meta:
model = Circuit
fields = [
'id', 'url', 'display', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate',
'description', 'termination_a', 'termination_z', 'comments', 'tags', 'custom_fields', 'created',
'last_updated',
'id', 'url', 'display', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'termination_date',
'commit_rate', 'description', 'termination_a', 'termination_z', 'comments', 'tags', 'custom_fields',
'created', 'last_updated',
]
class CircuitTerminationSerializer(ValidatedModelSerializer, LinkTerminationSerializer):
class CircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
circuit = NestedCircuitSerializer()
site = NestedSiteSerializer(required=False, allow_null=True)
provider_network = NestedProviderNetworkSerializer(required=False, allow_null=True)
cable = NestedCableSerializer(read_only=True)
class Meta:
model = CircuitTermination
fields = [
'id', 'url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed',
'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type',
'_occupied', 'created', 'last_updated',
'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers',
'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
]

View File

@@ -1,4 +1,4 @@
from netbox.api import NetBoxRouter
from netbox.api.routers import NetBoxRouter
from . import views

View File

@@ -58,7 +58,7 @@ class CircuitViewSet(NetBoxModelViewSet):
class CircuitTerminationViewSet(PassThroughPortMixin, NetBoxModelViewSet):
queryset = CircuitTermination.objects.prefetch_related(
'circuit', 'site', 'provider_network', 'cable'
'circuit', 'site', 'provider_network', 'cable__terminations'
)
serializer_class = serializers.CircuitTerminationSerializer
filterset_class = filtersets.CircuitTerminationFilterSet

View File

@@ -1,10 +1,10 @@
import django_filters
from django.db.models import Q
from dcim.filtersets import CableTerminationFilterSet
from dcim.filtersets import CabledObjectFilterSet
from dcim.models import Region, Site, SiteGroup
from ipam.models import ASN
from netbox.filtersets import ChangeLoggedModelFilterSet, NetBoxModelFilterSet, OrganizationalModelFilterSet
from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet
from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet
from utilities.filters import TreeNodeMultipleChoiceFilter
from .choices import *
@@ -183,7 +183,7 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte
class Meta:
model = Circuit
fields = ['id', 'cid', 'description', 'install_date', 'commit_rate']
fields = ['id', 'cid', 'description', 'install_date', 'termination_date', 'commit_rate']
def search(self, queryset, name, value):
if not value.strip():
@@ -198,7 +198,7 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte
).distinct()
class CircuitTerminationFilterSet(ChangeLoggedModelFilterSet, CableTerminationFilterSet):
class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@@ -224,7 +224,7 @@ class CircuitTerminationFilterSet(ChangeLoggedModelFilterSet, CableTerminationFi
class Meta:
model = CircuitTermination
fields = ['id', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id', 'description']
fields = ['id', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id', 'description', 'cable_end']
def search(self, queryset, name, value):
if not value.strip():

View File

@@ -7,7 +7,7 @@ from ipam.models import ASN
from netbox.forms import NetBoxModelBulkEditForm
from tenancy.models import Tenant
from utilities.forms import (
add_blank_choice, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea,
add_blank_choice, CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea,
StaticSelect,
)
@@ -122,6 +122,14 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm):
queryset=Tenant.objects.all(),
required=False
)
install_date = forms.DateField(
required=False,
widget=DatePicker()
)
termination_date = forms.DateField(
required=False,
widget=DatePicker()
)
commit_rate = forms.IntegerField(
required=False,
label='Commit rate (Kbps)'
@@ -137,7 +145,9 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm):
model = Circuit
fieldsets = (
(None, ('type', 'provider', 'status', 'tenant', 'commit_rate', 'description')),
('Circuit', ('provider', 'type', 'status', 'description')),
('Service Parameters', ('install_date', 'termination_date', 'commit_rate')),
('Tenancy', ('tenant',)),
)
nullable_fields = (
'tenant', 'commit_rate', 'description', 'comments',

View File

@@ -72,5 +72,6 @@ class CircuitCSVForm(NetBoxModelCSVForm):
class Meta:
model = Circuit
fields = [
'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'termination_date', 'commit_rate',
'description', 'comments',
]

View File

@@ -7,7 +7,7 @@ from dcim.models import Region, Site, SiteGroup
from ipam.models import ASN
from netbox.forms import NetBoxModelFilterSetForm
from tenancy.forms import TenancyFilterForm, ContactModelFilterForm
from utilities.forms import DynamicModelMultipleChoiceField, MultipleChoiceField, TagFilterField
from utilities.forms import DatePicker, DynamicModelMultipleChoiceField, MultipleChoiceField, TagFilterField
__all__ = (
'CircuitFilterForm',
@@ -84,7 +84,7 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
fieldsets = (
(None, ('q', 'tag')),
('Provider', ('provider_id', 'provider_network_id')),
('Attributes', ('type_id', 'status', 'commit_rate')),
('Attributes', ('type_id', 'status', 'install_date', 'termination_date', 'commit_rate')),
('Location', ('region_id', 'site_group_id', 'site_id')),
('Tenant', ('tenant_group_id', 'tenant_id')),
('Contacts', ('contact', 'contact_role', 'contact_group')),
@@ -130,6 +130,14 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
},
label=_('Site')
)
install_date = forms.DateField(
required=False,
widget=DatePicker
)
termination_date = forms.DateField(
required=False,
widget=DatePicker
)
commit_rate = forms.IntegerField(
required=False,
min_value=0,

View File

@@ -93,15 +93,16 @@ class CircuitForm(TenancyForm, NetBoxModelForm):
comments = CommentField()
fieldsets = (
('Circuit', ('provider', 'cid', 'type', 'status', 'install_date', 'commit_rate', 'description', 'tags')),
('Circuit', ('provider', 'cid', 'type', 'status', 'description', 'tags')),
('Service Parameters', ('install_date', 'termination_date', 'commit_rate')),
('Tenancy', ('tenant_group', 'tenant')),
)
class Meta:
model = Circuit
fields = [
'cid', 'type', 'provider', 'status', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant',
'comments', 'tags',
'cid', 'type', 'provider', 'status', 'install_date', 'termination_date', 'commit_rate', 'description',
'tenant_group', 'tenant', 'comments', 'tags',
]
help_texts = {
'cid': "Unique circuit ID",
@@ -110,11 +111,12 @@ class CircuitForm(TenancyForm, NetBoxModelForm):
widgets = {
'status': StaticSelect(),
'install_date': DatePicker(),
'termination_date': DatePicker(),
'commit_rate': SelectSpeedWidget(),
}
class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
class CircuitTerminationForm(NetBoxModelForm):
provider = DynamicModelChoiceField(
queryset=Provider.objects.all(),
required=False,
@@ -159,7 +161,7 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
model = CircuitTermination
fields = [
'provider', 'circuit', 'term_side', 'region', 'site_group', 'site', 'provider_network', 'mark_connected',
'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description',
'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description', 'tags',
]
help_texts = {
'port_speed': "Physical circuit speed",

View File

@@ -1,4 +1,6 @@
from circuits import filtersets, models
from dcim.graphql.mixins import CabledObjectMixin
from extras.graphql.mixins import CustomFieldsMixin, TagsMixin
from netbox.graphql.types import ObjectType, OrganizationalObjectType, NetBoxObjectType
__all__ = (
@@ -10,7 +12,7 @@ __all__ = (
)
class CircuitTerminationType(ObjectType):
class CircuitTerminationType(CustomFieldsMixin, TagsMixin, CabledObjectMixin, ObjectType):
class Meta:
model = models.CircuitTermination

View File

@@ -0,0 +1,28 @@
import django.core.serializers.json
from django.db import migrations, models
import taggit.managers
class Migration(migrations.Migration):
dependencies = [
('circuits', '0035_provider_asns'),
]
operations = [
migrations.AddField(
model_name='circuit',
name='termination_date',
field=models.DateField(blank=True, null=True),
),
migrations.AddField(
model_name='circuittermination',
name='custom_field_data',
field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
),
migrations.AddField(
model_name='circuittermination',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
]

View File

@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('circuits', '0036_circuit_termination_date_tags_custom_fields'),
]
operations = [
migrations.AddField(
model_name='circuittermination',
name='cable_end',
field=models.CharField(blank=True, max_length=1),
),
]

View File

@@ -0,0 +1,20 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('circuits', '0037_new_cabling_models'),
('dcim', '0160_populate_cable_ends'),
]
operations = [
migrations.RemoveField(
model_name='circuittermination',
name='_link_peer_id',
),
migrations.RemoveField(
model_name='circuittermination',
name='_link_peer_type',
),
]

View File

@@ -4,8 +4,10 @@ from django.db import models
from django.urls import reverse
from circuits.choices import *
from dcim.models import LinkTermination
from netbox.models import ChangeLoggedModel, OrganizationalModel, NetBoxModel
from dcim.models import CabledObjectModel
from netbox.models import (
ChangeLoggedModel, CustomFieldsMixin, CustomLinksMixin, OrganizationalModel, NetBoxModel, TagsMixin,
)
from netbox.models.features import WebhooksMixin
__all__ = (
@@ -78,7 +80,12 @@ class Circuit(NetBoxModel):
install_date = models.DateField(
blank=True,
null=True,
verbose_name='Date installed'
verbose_name='Installed'
)
termination_date = models.DateField(
blank=True,
null=True,
verbose_name='Terminates'
)
commit_rate = models.PositiveIntegerField(
blank=True,
@@ -119,7 +126,7 @@ class Circuit(NetBoxModel):
)
clone_fields = [
'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description',
'provider', 'type', 'status', 'tenant', 'install_date', 'termination_date', 'commit_rate', 'description',
]
class Meta:
@@ -136,7 +143,14 @@ class Circuit(NetBoxModel):
return CircuitStatusChoices.colors.get(self.status)
class CircuitTermination(WebhooksMixin, ChangeLoggedModel, LinkTermination):
class CircuitTermination(
CustomFieldsMixin,
CustomLinksMixin,
TagsMixin,
WebhooksMixin,
ChangeLoggedModel,
CabledObjectModel
):
circuit = models.ForeignKey(
to='circuits.Circuit',
on_delete=models.CASCADE,

View File

@@ -24,4 +24,4 @@ def rebuild_cablepaths(instance, raw=False, **kwargs):
if not raw:
peer_termination = instance.get_peer_termination()
if peer_termination:
rebuild_paths(peer_termination)
rebuild_paths([peer_termination])

View File

@@ -68,8 +68,9 @@ class CircuitTable(TenancyColumnsMixin, NetBoxTable):
class Meta(NetBoxTable.Meta):
model = Circuit
fields = (
'pk', 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'tenant_group', 'termination_a', 'termination_z', 'install_date',
'commit_rate', 'description', 'comments', 'contacts', 'tags', 'created', 'last_updated',
'pk', 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'tenant_group', 'termination_a', 'termination_z',
'install_date', 'termination_date', 'commit_rate', 'description', 'comments', 'contacts', 'tags', 'created',
'last_updated',
)
default_columns = (
'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description',

View File

@@ -208,12 +208,12 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
ProviderNetwork.objects.bulk_create(provider_networks)
circuits = (
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, description='foobar1'),
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, description='foobar2'),
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(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 1', install_date='2020-01-01', termination_date='2021-01-01', commit_rate=1000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar1'),
Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 2', install_date='2020-01-02', termination_date='2021-01-02', commit_rate=2000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar2'),
Circuit(provider=providers[0], tenant=tenants[1], type=circuit_types[0], cid='Test Circuit 3', install_date='2020-01-03', termination_date='2021-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', termination_date='2021-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', termination_date='2021-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', termination_date='2021-01-06', commit_rate=6000, status=CircuitStatusChoices.STATUS_OFFLINE),
)
Circuit.objects.bulk_create(circuits)
@@ -235,6 +235,10 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'install_date': ['2020-01-01', '2020-01-02']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_termination_date(self):
params = {'termination_date': ['2021-01-01', '2021-01-02']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_commit_rate(self):
params = {'commit_rate': ['1000', '2000']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -356,7 +360,7 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
))
CircuitTermination.objects.bulk_create(circuit_terminations)
Cable(termination_a=circuit_terminations[0], termination_b=circuit_terminations[1]).save()
Cable(a_terminations=[circuit_terminations[0]], b_terminations=[circuit_terminations[1]]).save()
def test_term_side(self):
params = {'term_side': 'A'}

View File

@@ -130,6 +130,7 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'status': CircuitStatusChoices.STATUS_DECOMMISSIONED,
'tenant': None,
'install_date': datetime.date(2020, 1, 1),
'termination_date': datetime.date(2021, 1, 1),
'commit_rate': 1000,
'description': 'A new circuit',
'comments': 'Some comments',
@@ -245,7 +246,7 @@ class CircuitTerminationTestCase(
device=device,
name='Interface 1'
)
Cable(termination_a=circuittermination, termination_b=interface).save()
Cable(a_terminations=[circuittermination], b_terminations=[interface]).save()
response = self.client.get(reverse('circuits:circuittermination_trace', kwargs={'pk': circuittermination.pk}))
self.assertHttpStatus(response, 200)

View File

@@ -1,6 +1,6 @@
from django.urls import path
from dcim.views import CableCreateView, PathTraceView
from dcim.views import PathTraceView
from netbox.views.generic import ObjectChangeLogView, ObjectJournalView
from . import views
from .models import *
@@ -60,7 +60,6 @@ urlpatterns = [
path('circuit-terminations/add/', views.CircuitTerminationEditView.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/', PathTraceView.as_view(), name='circuittermination_trace', kwargs={'model': CircuitTermination}),
]

View File

@@ -30,7 +30,8 @@ class ProviderView(generic.ObjectView):
circuits = Circuit.objects.restrict(request.user, 'view').filter(
provider=instance
).prefetch_related(
'type', 'tenant', 'tenant__group', 'terminations__site'
'tenant__group', 'termination_a__site', 'termination_z__site',
'termination_a__provider_network', 'termination_z__provider_network',
)
circuits_table = tables.CircuitTable(circuits, user=request.user, exclude=('provider',))
circuits_table.configure(request)
@@ -91,7 +92,8 @@ class ProviderNetworkView(generic.ObjectView):
Q(termination_a__provider_network=instance.pk) |
Q(termination_z__provider_network=instance.pk)
).prefetch_related(
'type', 'tenant', 'tenant__group', 'terminations__site'
'tenant__group', 'termination_a__site', 'termination_z__site',
'termination_a__provider_network', 'termination_z__provider_network',
)
circuits_table = tables.CircuitTable(circuits, user=request.user)
circuits_table.configure(request)
@@ -192,7 +194,8 @@ class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
class CircuitListView(generic.ObjectListView):
queryset = Circuit.objects.prefetch_related(
'provider', 'type', 'tenant', 'tenant__group', 'termination_a', 'termination_z'
'tenant__group', 'termination_a__site', 'termination_z__site',
'termination_a__provider_network', 'termination_z__provider_network',
)
filterset = filtersets.CircuitFilterSet
filterset_form = forms.CircuitFilterForm
@@ -220,7 +223,8 @@ class CircuitBulkImportView(generic.BulkImportView):
class CircuitBulkEditView(generic.BulkEditView):
queryset = Circuit.objects.prefetch_related(
'provider', 'type', 'tenant', 'terminations'
'termination_a__site', 'termination_z__site',
'termination_a__provider_network', 'termination_z__provider_network',
)
filterset = filtersets.CircuitFilterSet
table = tables.CircuitTable
@@ -229,7 +233,8 @@ class CircuitBulkEditView(generic.BulkEditView):
class CircuitBulkDeleteView(generic.BulkDeleteView):
queryset = Circuit.objects.prefetch_related(
'provider', 'type', 'tenant', 'terminations'
'termination_a__site', 'termination_z__site',
'termination_a__provider_network', 'termination_z__provider_network',
)
filterset = filtersets.CircuitFilterSet
table = tables.CircuitTable

View File

@@ -1,3 +1,5 @@
import decimal
from django.contrib.contenttypes.models import ContentType
from drf_yasg.utils import swagger_serializer_method
from rest_framework import serializers
@@ -7,12 +9,14 @@ from dcim.choices import *
from dcim.constants import *
from dcim.models import *
from ipam.api.nested_serializers import (
NestedASNSerializer, NestedIPAddressSerializer, NestedVLANSerializer, NestedVRFSerializer,
NestedASNSerializer, NestedIPAddressSerializer, NestedL2VPNTerminationSerializer, NestedVLANSerializer,
NestedVRFSerializer,
)
from ipam.models import ASN, VLAN
from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
from netbox.api.serializers import (
NestedGroupModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer, WritableNestedSerializer,
GenericObjectSerializer, NestedGroupModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer,
WritableNestedSerializer,
)
from netbox.config import ConfigItem
from netbox.constants import NESTED_SERIALIZER_PREFIX
@@ -26,58 +30,68 @@ from wireless.models import WirelessLAN
from .nested_serializers import *
class LinkTerminationSerializer(serializers.ModelSerializer):
link_peer_type = serializers.SerializerMethodField(read_only=True)
link_peer = serializers.SerializerMethodField(read_only=True)
class CabledObjectSerializer(serializers.ModelSerializer):
cable = NestedCableSerializer(read_only=True)
cable_end = serializers.CharField(read_only=True)
link_peers_type = serializers.SerializerMethodField(read_only=True)
link_peers = serializers.SerializerMethodField(read_only=True)
_occupied = serializers.SerializerMethodField(read_only=True)
def get_link_peer_type(self, obj):
if obj._link_peer is not None:
return f'{obj._link_peer._meta.app_label}.{obj._link_peer._meta.model_name}'
def get_link_peers_type(self, obj):
"""
Return the type of the peer link terminations, or None.
"""
if not obj.cable:
return None
if obj.link_peers:
return f'{obj.link_peers[0]._meta.app_label}.{obj.link_peers[0]._meta.model_name}'
return None
@swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_link_peer(self, obj):
@swagger_serializer_method(serializer_or_field=serializers.ListField)
def get_link_peers(self, obj):
"""
Return the appropriate serializer for the link termination model.
"""
if obj._link_peer is not None:
serializer = get_serializer_for_model(obj._link_peer, prefix=NESTED_SERIALIZER_PREFIX)
context = {'request': self.context['request']}
return serializer(obj._link_peer, context=context).data
return None
if not obj.link_peers:
return []
# Return serialized peer termination objects
serializer = get_serializer_for_model(obj.link_peers[0], prefix=NESTED_SERIALIZER_PREFIX)
context = {'request': self.context['request']}
return serializer(obj.link_peers, context=context, many=True).data
@swagger_serializer_method(serializer_or_field=serializers.BooleanField)
def get__occupied(self, obj):
return obj._occupied
class ConnectedEndpointSerializer(serializers.ModelSerializer):
connected_endpoint_type = serializers.SerializerMethodField(read_only=True)
connected_endpoint = serializers.SerializerMethodField(read_only=True)
connected_endpoint_reachable = serializers.SerializerMethodField(read_only=True)
class ConnectedEndpointsSerializer(serializers.ModelSerializer):
"""
Legacy serializer for pre-v3.3 connections
"""
connected_endpoints_type = serializers.SerializerMethodField(read_only=True)
connected_endpoints = serializers.SerializerMethodField(read_only=True)
connected_endpoints_reachable = serializers.SerializerMethodField(read_only=True)
def get_connected_endpoint_type(self, obj):
if obj._path is not None and obj._path.destination is not None:
return f'{obj._path.destination._meta.app_label}.{obj._path.destination._meta.model_name}'
return None
def get_connected_endpoints_type(self, obj):
if endpoints := obj.connected_endpoints:
return f'{endpoints[0]._meta.app_label}.{endpoints[0]._meta.model_name}'
@swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_connected_endpoint(self, obj):
@swagger_serializer_method(serializer_or_field=serializers.ListField)
def get_connected_endpoints(self, obj):
"""
Return the appropriate serializer for the type of connected object.
"""
if obj._path is not None and obj._path.destination is not None:
serializer = get_serializer_for_model(obj._path.destination, prefix=NESTED_SERIALIZER_PREFIX)
if endpoints := obj.connected_endpoints:
serializer = get_serializer_for_model(endpoints[0], prefix=NESTED_SERIALIZER_PREFIX)
context = {'request': self.context['request']}
return serializer(obj._path.destination, context=context).data
return None
return serializer(endpoints, many=True, context=context).data
@swagger_serializer_method(serializer_or_field=serializers.BooleanField)
def get_connected_endpoint_reachable(self, obj):
if obj._path is not None:
return obj._path.is_active
return None
def get_connected_endpoints_reachable(self, obj):
return obj._path and obj._path.is_complete and obj._path.is_active
#
@@ -150,6 +164,7 @@ class LocationSerializer(NestedGroupModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:location-detail')
site = NestedSiteSerializer()
parent = NestedLocationSerializer(required=False, allow_null=True)
status = ChoiceField(choices=LocationStatusChoices, required=False)
tenant = NestedTenantSerializer(required=False, allow_null=True)
rack_count = serializers.IntegerField(read_only=True)
device_count = serializers.IntegerField(read_only=True)
@@ -157,8 +172,8 @@ class LocationSerializer(NestedGroupModelSerializer):
class Meta:
model = Location
fields = [
'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'tenant', 'description', 'tags', 'custom_fields',
'created', 'last_updated', 'rack_count', 'device_count', '_depth',
'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'status', 'tenant', 'description', 'tags',
'custom_fields', 'created', 'last_updated', 'rack_count', 'device_count', '_depth',
]
@@ -202,7 +217,11 @@ class RackUnitSerializer(serializers.Serializer):
"""
A rack unit is an abstraction formed by the set (rack, position, face); it does not exist as a row in the database.
"""
id = serializers.IntegerField(read_only=True)
id = serializers.DecimalField(
max_digits=4,
decimal_places=1,
read_only=True
)
name = serializers.CharField(read_only=True)
face = ChoiceField(choices=DeviceFaceChoices, read_only=True)
device = NestedDeviceSerializer(read_only=True)
@@ -247,7 +266,10 @@ class RackElevationDetailFilterSerializer(serializers.Serializer):
default=ConfigItem('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT')
)
legend_width = serializers.IntegerField(
default=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT
default=RACK_ELEVATION_DEFAULT_LEGEND_WIDTH
)
margin_width = serializers.IntegerField(
default=RACK_ELEVATION_DEFAULT_MARGIN_WIDTH
)
exclude = serializers.IntegerField(
required=False,
@@ -284,6 +306,13 @@ class ManufacturerSerializer(NetBoxModelSerializer):
class DeviceTypeSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail')
manufacturer = NestedManufacturerSerializer()
u_height = serializers.DecimalField(
max_digits=4,
decimal_places=1,
label='Position (U)',
min_value=decimal.Decimal(0.5),
default=1.0
)
subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False)
airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False)
device_count = serializers.IntegerField(read_only=True)
@@ -440,12 +469,22 @@ class InterfaceTemplateSerializer(ValidatedModelSerializer):
default=None
)
type = ChoiceField(choices=InterfaceTypeChoices)
poe_mode = ChoiceField(
choices=InterfacePoEModeChoices,
required=False,
allow_blank=True
)
poe_type = ChoiceField(
choices=InterfacePoETypeChoices,
required=False,
allow_blank=True
)
class Meta:
model = InterfaceTemplate
fields = [
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description',
'created', 'last_updated',
'poe_mode', 'poe_type', 'created', 'last_updated',
]
@@ -590,7 +629,14 @@ class DeviceSerializer(NetBoxModelSerializer):
location = NestedLocationSerializer(required=False, allow_null=True, default=None)
rack = NestedRackSerializer(required=False, allow_null=True, default=None)
face = ChoiceField(choices=DeviceFaceChoices, allow_blank=True, default='')
position = serializers.IntegerField(allow_null=True, label='Position (U)', min_value=1, default=None)
position = serializers.DecimalField(
max_digits=4,
decimal_places=1,
allow_null=True,
label='Position (U)',
min_value=decimal.Decimal(0.5),
default=None
)
status = ChoiceField(choices=DeviceStatusChoices, required=False)
airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False)
primary_ip = NestedIPAddressSerializer(read_only=True)
@@ -660,7 +706,7 @@ class DeviceNAPALMSerializer(serializers.Serializer):
# Device components
#
class ConsoleServerPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
class ConsoleServerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail')
device = NestedDeviceSerializer()
module = ComponentNestedModuleSerializer(
@@ -677,18 +723,18 @@ class ConsoleServerPortSerializer(NetBoxModelSerializer, LinkTerminationSerializ
allow_null=True,
required=False
)
cable = NestedCableSerializer(read_only=True)
class Meta:
model = ConsoleServerPort
fields = [
'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description',
'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type',
'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'connected_endpoints',
'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
'last_updated', '_occupied',
]
class ConsolePortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
class ConsolePortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail')
device = NestedDeviceSerializer()
module = ComponentNestedModuleSerializer(
@@ -705,18 +751,18 @@ class ConsolePortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Co
allow_null=True,
required=False
)
cable = NestedCableSerializer(read_only=True)
class Meta:
model = ConsolePort
fields = [
'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description',
'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type',
'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'connected_endpoints',
'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
'last_updated', '_occupied',
]
class PowerOutletSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
class PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail')
device = NestedDeviceSerializer()
module = ComponentNestedModuleSerializer(
@@ -737,21 +783,18 @@ class PowerOutletSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Co
allow_blank=True,
required=False
)
cable = NestedCableSerializer(
read_only=True
)
class Meta:
model = PowerOutlet
fields = [
'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'power_port', 'feed_leg',
'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint',
'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created',
'last_updated', '_occupied',
'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type',
'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields',
'created', 'last_updated', '_occupied',
]
class PowerPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
class PowerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail')
device = NestedDeviceSerializer()
module = ComponentNestedModuleSerializer(
@@ -763,19 +806,18 @@ class PowerPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn
allow_blank=True,
required=False
)
cable = NestedCableSerializer(read_only=True)
class Meta:
model = PowerPort
fields = [
'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw',
'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint',
'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created',
'last_updated', '_occupied',
'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type',
'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields',
'created', 'last_updated', '_occupied',
]
class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
device = NestedDeviceSerializer()
module = ComponentNestedModuleSerializer(
@@ -790,6 +832,8 @@ class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn
duplex = ChoiceField(choices=InterfaceDuplexChoices, required=False, allow_blank=True)
rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_blank=True)
rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False, allow_blank=True)
poe_mode = ChoiceField(choices=InterfacePoEModeChoices, required=False, allow_blank=True)
poe_type = ChoiceField(choices=InterfacePoETypeChoices, required=False, allow_blank=True)
untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
tagged_vlans = SerializedPKRelatedField(
queryset=VLAN.objects.all(),
@@ -798,7 +842,7 @@ class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn
many=True
)
vrf = NestedVRFSerializer(required=False, allow_null=True)
cable = NestedCableSerializer(read_only=True)
l2vpn_termination = NestedL2VPNTerminationSerializer(read_only=True)
wireless_link = NestedWirelessLinkSerializer(read_only=True)
wireless_lans = SerializedPKRelatedField(
queryset=WirelessLAN.objects.all(),
@@ -814,10 +858,11 @@ class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn
fields = [
'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag',
'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel',
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'mark_connected',
'cable', 'wireless_link', 'link_peer', 'link_peer_type', 'wireless_lans', 'vrf', 'connected_endpoint',
'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created',
'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan',
'tagged_vlans', 'mark_connected', 'cable', 'cable_end', 'wireless_link', 'link_peers', 'link_peers_type',
'wireless_lans', 'vrf', 'l2vpn_termination', 'connected_endpoints', 'connected_endpoints_type',
'connected_endpoints_reachable', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses',
'count_fhrp_groups', '_occupied',
]
def validate(self, data):
@@ -834,7 +879,7 @@ class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn
return super().validate(data)
class RearPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer):
class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail')
device = NestedDeviceSerializer()
module = ComponentNestedModuleSerializer(
@@ -842,13 +887,12 @@ class RearPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer):
allow_null=True
)
type = ChoiceField(choices=PortTypeChoices)
cable = NestedCableSerializer(read_only=True)
class Meta:
model = RearPort
fields = [
'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'description',
'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'tags', 'custom_fields', 'created',
'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'tags', 'custom_fields', 'created',
'last_updated', '_occupied',
]
@@ -864,7 +908,7 @@ class FrontPortRearPortSerializer(WritableNestedSerializer):
fields = ['id', 'url', 'display', 'name', 'label']
class FrontPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer):
class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail')
device = NestedDeviceSerializer()
module = ComponentNestedModuleSerializer(
@@ -873,14 +917,13 @@ class FrontPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer):
)
type = ChoiceField(choices=PortTypeChoices)
rear_port = FrontPortRearPortSerializer()
cable = NestedCableSerializer(read_only=True)
class Meta:
model = FrontPort
fields = [
'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'rear_port',
'rear_port_position', 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'tags',
'custom_fields', 'created', 'last_updated', '_occupied',
'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers',
'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
]
@@ -963,14 +1006,8 @@ class InventoryItemRoleSerializer(NetBoxModelSerializer):
class CableSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail')
termination_a_type = ContentTypeField(
queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS)
)
termination_b_type = ContentTypeField(
queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS)
)
termination_a = serializers.SerializerMethodField(read_only=True)
termination_b = serializers.SerializerMethodField(read_only=True)
a_terminations = GenericObjectSerializer(many=True, required=False)
b_terminations = GenericObjectSerializer(many=True, required=False)
status = ChoiceField(choices=LinkStatusChoices, required=False)
tenant = NestedTenantSerializer(required=False, allow_null=True)
length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False)
@@ -978,34 +1015,10 @@ class CableSerializer(NetBoxModelSerializer):
class Meta:
model = Cable
fields = [
'id', 'url', 'display', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type',
'termination_b_id', 'termination_b', 'type', 'status', 'tenant', 'label', 'color', 'length', 'length_unit',
'tags', 'custom_fields', 'created', 'last_updated',
'id', 'url', 'display', 'type', 'a_terminations', 'b_terminations', 'status', 'tenant', 'label', 'color',
'length', 'length_unit', 'tags', 'custom_fields', 'created', 'last_updated',
]
def _get_termination(self, obj, side):
"""
Serialize a nested representation of a termination.
"""
if side.lower() not in ['a', 'b']:
raise ValueError("Termination side must be either A or B.")
termination = getattr(obj, 'termination_{}'.format(side.lower()))
if termination is None:
return None
serializer = get_serializer_for_model(termination, prefix=NESTED_SERIALIZER_PREFIX)
context = {'request': self.context['request']}
data = serializer(termination, context=context).data
return data
@swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_termination_a(self, obj):
return self._get_termination(obj, 'a')
@swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_termination_b(self, obj):
return self._get_termination(obj, 'b')
class TracedCableSerializer(serializers.ModelSerializer):
"""
@@ -1020,46 +1033,40 @@ class TracedCableSerializer(serializers.ModelSerializer):
]
class CableTerminationSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cabletermination-detail')
termination_type = ContentTypeField(
queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS)
)
termination = serializers.SerializerMethodField(read_only=True)
class Meta:
model = CableTermination
fields = [
'id', 'url', 'display', 'cable', 'cable_end', 'termination_type', 'termination_id', 'termination'
]
@swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_termination(self, obj):
serializer = get_serializer_for_model(obj.termination, prefix=NESTED_SERIALIZER_PREFIX)
context = {'request': self.context['request']}
return serializer(obj.termination, context=context).data
class CablePathSerializer(serializers.ModelSerializer):
origin_type = ContentTypeField(read_only=True)
origin = serializers.SerializerMethodField(read_only=True)
destination_type = ContentTypeField(read_only=True)
destination = serializers.SerializerMethodField(read_only=True)
path = serializers.SerializerMethodField(read_only=True)
class Meta:
model = CablePath
fields = [
'id', 'origin_type', 'origin', 'destination_type', 'destination', 'path', 'is_active', 'is_split',
]
@swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_origin(self, obj):
"""
Return the appropriate serializer for the origin.
"""
serializer = get_serializer_for_model(obj.origin, prefix=NESTED_SERIALIZER_PREFIX)
context = {'request': self.context['request']}
return serializer(obj.origin, context=context).data
@swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_destination(self, obj):
"""
Return the appropriate serializer for the destination, if any.
"""
if obj.destination_id is not None:
serializer = get_serializer_for_model(obj.destination, prefix=NESTED_SERIALIZER_PREFIX)
context = {'request': self.context['request']}
return serializer(obj.destination, context=context).data
return None
fields = ['id', 'path', 'is_active', 'is_complete', 'is_split']
@swagger_serializer_method(serializer_or_field=serializers.ListField)
def get_path(self, obj):
ret = []
for node in obj.get_path():
serializer = get_serializer_for_model(node, prefix=NESTED_SERIALIZER_PREFIX)
for nodes in obj.path_objects:
serializer = get_serializer_for_model(nodes[0], prefix=NESTED_SERIALIZER_PREFIX)
context = {'request': self.context['request']}
ret.append(serializer(node, context=context).data)
ret.append(serializer(nodes, context=context, many=True).data)
return ret
@@ -1102,7 +1109,7 @@ class PowerPanelSerializer(NetBoxModelSerializer):
]
class PowerFeedSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
class PowerFeedSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail')
power_panel = NestedPowerPanelSerializer()
rack = NestedRackSerializer(
@@ -1126,13 +1133,12 @@ class PowerFeedSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn
choices=PowerFeedPhaseChoices,
default=PowerFeedPhaseChoices.PHASE_SINGLE
)
cable = NestedCableSerializer(read_only=True)
class Meta:
model = PowerFeed
fields = [
'id', 'url', 'display', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage',
'amperage', 'max_utilization', 'comments', 'mark_connected', 'cable', 'link_peer', 'link_peer_type',
'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields',
'created', 'last_updated', '_occupied',
'amperage', 'max_utilization', 'comments', 'mark_connected', 'cable', 'cable_end', 'link_peers',
'link_peers_type', 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable',
'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
]

View File

@@ -1,4 +1,4 @@
from netbox.api import NetBoxRouter
from netbox.api.routers import NetBoxRouter
from . import views
@@ -56,6 +56,7 @@ router.register('inventory-item-roles', views.InventoryItemRoleViewSet)
# Cables
router.register('cables', views.CableViewSet)
router.register('cable-terminations', views.CableTerminationViewSet)
# Virtual chassis
router.register('virtual-chassis', views.VirtualChassisViewSet)

View File

@@ -1,5 +1,4 @@
import socket
from collections import OrderedDict
from django.http import Http404, HttpResponse, HttpResponseForbidden
from django.shortcuts import get_object_or_404
@@ -13,7 +12,9 @@ from rest_framework.viewsets import ViewSet
from circuits.models import Circuit
from dcim import filtersets
from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
from dcim.models import *
from dcim.svg import CableTraceSVG
from extras.api.views import ConfigContextQuerySetMixin
from ipam.models import Prefix, VLAN
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
@@ -52,37 +53,30 @@ class PathEndpointMixin(object):
# Initialize the path array
path = []
# Render SVG image if requested
if request.GET.get('render', None) == 'svg':
# Render SVG
try:
width = min(int(request.GET.get('width')), 1600)
width = int(request.GET.get('width', CABLE_TRACE_SVG_DEFAULT_WIDTH))
except (ValueError, TypeError):
width = None
drawing = obj.get_trace_svg(
base_url=request.build_absolute_uri('/'),
width=width
)
return HttpResponse(drawing.tostring(), content_type='image/svg+xml')
width = CABLE_TRACE_SVG_DEFAULT_WIDTH
drawing = CableTraceSVG(obj, base_url=request.build_absolute_uri('/'), width=width)
return HttpResponse(drawing.render().tostring(), content_type='image/svg+xml')
for near_end, cable, far_end in obj.trace():
if near_end is None:
# Split paths
# Serialize path objects, iterating over each three-tuple in the path
for near_ends, cable, far_ends in obj.trace():
if near_ends:
serializer_a = get_serializer_for_model(near_ends[0], prefix=NESTED_SERIALIZER_PREFIX)
near_ends = serializer_a(near_ends, many=True, context={'request': request}).data
else:
# Path is split; stop here
break
if cable:
cable = serializers.TracedCableSerializer(cable[0], context={'request': request}).data
if far_ends:
serializer_b = get_serializer_for_model(far_ends[0], prefix=NESTED_SERIALIZER_PREFIX)
far_ends = serializer_b(far_ends, many=True, context={'request': request}).data
# Serialize each object
serializer_a = get_serializer_for_model(near_end, prefix=NESTED_SERIALIZER_PREFIX)
x = serializer_a(near_end, context={'request': request}).data
if cable is not None:
y = serializers.TracedCableSerializer(cable, context={'request': request}).data
else:
y = None
if far_end is not None:
serializer_b = get_serializer_for_model(far_end, prefix=NESTED_SERIALIZER_PREFIX)
z = serializer_b(far_end, context={'request': request}).data
else:
z = None
path.append((x, y, z))
path.append((near_ends, cable, far_ends))
return Response(path)
@@ -95,7 +89,7 @@ class PassThroughPortMixin(object):
Return all CablePaths which traverse a given pass-through port.
"""
obj = get_object_or_404(self.queryset, pk=pk)
cablepaths = CablePath.objects.filter(path__contains=obj).prefetch_related('origin', 'destination')
cablepaths = CablePath.objects.filter(_nodes__contains=obj)
serializer = serializers.CablePathSerializer(cablepaths, context={'request': request}, many=True)
return Response(serializer.data)
@@ -216,6 +210,14 @@ class RackViewSet(NetBoxModelViewSet):
data = serializer.validated_data
if data['render'] == 'svg':
# Determine attributes for highlighting devices (if any)
highlight_params = []
for param in request.GET.getlist('highlight'):
try:
highlight_params.append(param.split(':', 1))
except ValueError:
pass
# Render and return the elevation as an SVG drawing with the correct content type
drawing = rack.get_elevation_svg(
face=data['face'],
@@ -224,7 +226,8 @@ class RackViewSet(NetBoxModelViewSet):
unit_height=data['unit_height'],
legend_width=data['legend_width'],
include_images=data['include_images'],
base_url=request.build_absolute_uri('/')
base_url=request.build_absolute_uri('/'),
highlight_params=highlight_params
)
return HttpResponse(drawing.tostring(), content_type='image/svg+xml')
@@ -480,7 +483,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet):
return HttpResponseForbidden()
napalm_methods = request.GET.getlist('method')
response = OrderedDict([(m, None) for m in napalm_methods])
response = {m: None for m in napalm_methods}
config = get_config()
username = config.NAPALM_USERNAME
@@ -549,7 +552,7 @@ class ModuleViewSet(NetBoxModelViewSet):
class ConsolePortViewSet(PathEndpointMixin, NetBoxModelViewSet):
queryset = ConsolePort.objects.prefetch_related(
'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags'
'device', 'module__module_bay', '_path', 'cable__terminations', 'tags'
)
serializer_class = serializers.ConsolePortSerializer
filterset_class = filtersets.ConsolePortFilterSet
@@ -558,7 +561,7 @@ class ConsolePortViewSet(PathEndpointMixin, NetBoxModelViewSet):
class ConsoleServerPortViewSet(PathEndpointMixin, NetBoxModelViewSet):
queryset = ConsoleServerPort.objects.prefetch_related(
'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags'
'device', 'module__module_bay', '_path', 'cable__terminations', 'tags'
)
serializer_class = serializers.ConsoleServerPortSerializer
filterset_class = filtersets.ConsoleServerPortFilterSet
@@ -567,7 +570,7 @@ class ConsoleServerPortViewSet(PathEndpointMixin, NetBoxModelViewSet):
class PowerPortViewSet(PathEndpointMixin, NetBoxModelViewSet):
queryset = PowerPort.objects.prefetch_related(
'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags'
'device', 'module__module_bay', '_path', 'cable__terminations', 'tags'
)
serializer_class = serializers.PowerPortSerializer
filterset_class = filtersets.PowerPortFilterSet
@@ -576,7 +579,7 @@ class PowerPortViewSet(PathEndpointMixin, NetBoxModelViewSet):
class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet):
queryset = PowerOutlet.objects.prefetch_related(
'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags'
'device', 'module__module_bay', '_path', 'cable__terminations', 'tags'
)
serializer_class = serializers.PowerOutletSerializer
filterset_class = filtersets.PowerOutletFilterSet
@@ -585,8 +588,8 @@ class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet):
class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet):
queryset = Interface.objects.prefetch_related(
'device', 'module__module_bay', 'parent', 'bridge', 'lag', '_path__destination', 'cable', '_link_peer',
'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'vrf', 'ip_addresses', 'fhrp_group_assignments', 'tags'
'device', 'module__module_bay', 'parent', 'bridge', 'lag', '_path', 'cable__terminations', 'wireless_lans',
'untagged_vlan', 'tagged_vlans', 'vrf', 'ip_addresses', 'fhrp_group_assignments', 'tags'
)
serializer_class = serializers.InterfaceSerializer
filterset_class = filtersets.InterfaceFilterSet
@@ -595,7 +598,7 @@ class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet):
class FrontPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
queryset = FrontPort.objects.prefetch_related(
'device__device_type__manufacturer', 'module__module_bay', 'rear_port', 'cable', 'tags'
'device__device_type__manufacturer', 'module__module_bay', 'rear_port', 'cable__terminations', 'tags'
)
serializer_class = serializers.FrontPortSerializer
filterset_class = filtersets.FrontPortFilterSet
@@ -604,7 +607,7 @@ class FrontPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
class RearPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
queryset = RearPort.objects.prefetch_related(
'device__device_type__manufacturer', 'module__module_bay', 'cable', 'tags'
'device__device_type__manufacturer', 'module__module_bay', 'cable__terminations', 'tags'
)
serializer_class = serializers.RearPortSerializer
filterset_class = filtersets.RearPortFilterSet
@@ -649,14 +652,18 @@ class InventoryItemRoleViewSet(NetBoxModelViewSet):
#
class CableViewSet(NetBoxModelViewSet):
metadata_class = ContentTypeMetadata
queryset = Cable.objects.prefetch_related(
'termination_a', 'termination_b'
)
queryset = Cable.objects.prefetch_related('terminations__termination')
serializer_class = serializers.CableSerializer
filterset_class = filtersets.CableFilterSet
class CableTerminationViewSet(NetBoxModelViewSet):
metadata_class = ContentTypeMetadata
queryset = CableTermination.objects.prefetch_related('cable', 'termination')
serializer_class = serializers.CableTerminationSerializer
filterset_class = filtersets.CableTerminationFilterSet
#
# Virtual chassis
#
@@ -690,7 +697,7 @@ class PowerPanelViewSet(NetBoxModelViewSet):
class PowerFeedViewSet(PathEndpointMixin, NetBoxModelViewSet):
queryset = PowerFeed.objects.prefetch_related(
'power_panel', 'rack', '_path__destination', 'cable', '_link_peer', 'tags'
'power_panel', 'rack', '_path', 'cable__terminations', 'tags'
)
serializer_class = serializers.PowerFeedSerializer
filterset_class = filtersets.PowerFeedFilterSet
@@ -750,13 +757,13 @@ class ConnectedDeviceViewSet(ViewSet):
device=peer_device,
name=peer_interface_name
)
endpoint = peer_interface.connected_endpoint
endpoints = peer_interface.connected_endpoints
# If an Interface, return the parent device
if type(endpoint) is Interface:
if endpoints and type(endpoints[0]) is Interface:
device = get_object_or_404(
Device.objects.restrict(request.user, 'view'),
pk=endpoint.device_id
pk=endpoints[0].device_id
)
return Response(serializers.DeviceSerializer(device, context={'request': request}).data)

View File

@@ -1,10 +1,26 @@
from django.apps import AppConfig
from netbox import denormalized
class DCIMConfig(AppConfig):
name = "dcim"
verbose_name = "DCIM"
def ready(self):
import dcim.signals
from .models import CableTermination
# Register denormalized fields
denormalized.register(CableTermination, '_device', {
'_rack': 'rack',
'_location': 'location',
'_site': 'site',
})
denormalized.register(CableTermination, '_rack', {
'_location': 'location',
'_site': 'site',
})
denormalized.register(CableTermination, '_location', {
'_site': 'site',
})

View File

@@ -23,6 +23,28 @@ class SiteStatusChoices(ChoiceSet):
]
#
# Locations
#
class LocationStatusChoices(ChoiceSet):
key = 'Location.status'
STATUS_PLANNED = 'planned'
STATUS_STAGING = 'staging'
STATUS_ACTIVE = 'active'
STATUS_DECOMMISSIONING = 'decommissioning'
STATUS_RETIRED = 'retired'
CHOICES = [
(STATUS_PLANNED, 'Planned', 'cyan'),
(STATUS_STAGING, 'Staging', 'blue'),
(STATUS_ACTIVE, 'Active', 'green'),
(STATUS_DECOMMISSIONING, 'Decommissioning', 'yellow'),
(STATUS_RETIRED, 'Retired', 'red'),
]
#
# Racks
#
@@ -1003,6 +1025,51 @@ class InterfaceModeChoices(ChoiceSet):
)
class InterfacePoEModeChoices(ChoiceSet):
MODE_PD = 'pd'
MODE_PSE = 'pse'
CHOICES = (
(MODE_PD, 'PD'),
(MODE_PSE, 'PSE'),
)
class InterfacePoETypeChoices(ChoiceSet):
TYPE_1_8023AF = 'type1-ieee802.3af'
TYPE_2_8023AT = 'type2-ieee802.3at'
TYPE_3_8023BT = 'type3-ieee802.3bt'
TYPE_4_8023BT = 'type4-ieee802.3bt'
PASSIVE_24V_2PAIR = 'passive-24v-2pair'
PASSIVE_24V_4PAIR = 'passive-24v-4pair'
PASSIVE_48V_2PAIR = 'passive-48v-2pair'
PASSIVE_48V_4PAIR = 'passive-48v-4pair'
CHOICES = (
(
'IEEE Standard',
(
(TYPE_1_8023AF, '802.3af (Type 1)'),
(TYPE_2_8023AT, '802.3at (Type 2)'),
(TYPE_3_8023BT, '802.3bt (Type 3)'),
(TYPE_4_8023BT, '802.3bt (Type 4)'),
)
),
(
'Passive',
(
(PASSIVE_24V_2PAIR, 'Passive 24V (2-pair)'),
(PASSIVE_24V_4PAIR, 'Passive 24V (4-pair)'),
(PASSIVE_48V_2PAIR, 'Passive 48V (2-pair)'),
(PASSIVE_48V_2PAIR, 'Passive 48V (4-pair)'),
)
),
)
#
# FrontPorts/RearPorts
#
@@ -1215,6 +1282,22 @@ class CableLengthUnitChoices(ChoiceSet):
)
#
# CableTerminations
#
class CableEndChoices(ChoiceSet):
SIDE_A = 'A'
SIDE_B = 'B'
CHOICES = (
(SIDE_A, 'A'),
(SIDE_B, 'B'),
# ('', ''),
)
#
# PowerFeeds
#

View File

@@ -13,7 +13,8 @@ DEVICETYPE_IMAGE_FORMATS = 'image/bmp,image/gif,image/jpeg,image/png,image/tiff,
RACK_U_HEIGHT_DEFAULT = 42
RACK_ELEVATION_BORDER_WIDTH = 2
RACK_ELEVATION_LEGEND_WIDTH_DEFAULT = 30
RACK_ELEVATION_DEFAULT_LEGEND_WIDTH = 30
RACK_ELEVATION_DEFAULT_MARGIN_WIDTH = 15
#
@@ -84,6 +85,8 @@ MODULAR_COMPONENT_MODELS = Q(
# Cabling and connections
#
CABLE_TRACE_SVG_DEFAULT_WIDTH = 400
# Cable endpoint types
CABLE_TERMINATION_MODELS = Q(
Q(app_label='circuits', model__in=(

View File

@@ -21,6 +21,7 @@ from .models import *
__all__ = (
'CableFilterSet',
'CabledObjectFilterSet',
'CableTerminationFilterSet',
'ConsoleConnectionFilterSet',
'ConsolePortFilterSet',
@@ -216,10 +217,14 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalM
to_field_name='slug',
label='Location (slug)',
)
status = django_filters.MultipleChoiceFilter(
choices=LocationStatusChoices,
null_value=None
)
class Meta:
model = Location
fields = ['id', 'name', 'slug', 'description']
fields = ['id', 'name', 'slug', 'status', 'description']
def search(self, queryset, name, value):
if not value.strip():
@@ -647,6 +652,12 @@ class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo
choices=InterfaceTypeChoices,
null_value=None
)
poe_mode = django_filters.MultipleChoiceFilter(
choices=InterfacePoEModeChoices
)
poe_type = django_filters.MultipleChoiceFilter(
choices=InterfacePoETypeChoices
)
class Meta:
model = InterfaceTemplate
@@ -1116,7 +1127,7 @@ class ModularDeviceComponentFilterSet(DeviceComponentFilterSet):
)
class CableTerminationFilterSet(django_filters.FilterSet):
class CabledObjectFilterSet(django_filters.FilterSet):
cabled = django_filters.BooleanFilter(
field_name='cable',
lookup_expr='isnull',
@@ -1139,7 +1150,7 @@ class PathEndpointFilterSet(django_filters.FilterSet):
class ConsolePortFilterSet(
ModularDeviceComponentFilterSet,
NetBoxModelFilterSet,
CableTerminationFilterSet,
CabledObjectFilterSet,
PathEndpointFilterSet
):
type = django_filters.MultipleChoiceFilter(
@@ -1149,13 +1160,13 @@ class ConsolePortFilterSet(
class Meta:
model = ConsolePort
fields = ['id', 'name', 'label', 'description']
fields = ['id', 'name', 'label', 'description', 'cable_end']
class ConsoleServerPortFilterSet(
ModularDeviceComponentFilterSet,
NetBoxModelFilterSet,
CableTerminationFilterSet,
CabledObjectFilterSet,
PathEndpointFilterSet
):
type = django_filters.MultipleChoiceFilter(
@@ -1165,13 +1176,13 @@ class ConsoleServerPortFilterSet(
class Meta:
model = ConsoleServerPort
fields = ['id', 'name', 'label', 'description']
fields = ['id', 'name', 'label', 'description', 'cable_end']
class PowerPortFilterSet(
ModularDeviceComponentFilterSet,
NetBoxModelFilterSet,
CableTerminationFilterSet,
CabledObjectFilterSet,
PathEndpointFilterSet
):
type = django_filters.MultipleChoiceFilter(
@@ -1181,13 +1192,13 @@ class PowerPortFilterSet(
class Meta:
model = PowerPort
fields = ['id', 'name', 'label', 'maximum_draw', 'allocated_draw', 'description']
fields = ['id', 'name', 'label', 'maximum_draw', 'allocated_draw', 'description', 'cable_end']
class PowerOutletFilterSet(
ModularDeviceComponentFilterSet,
NetBoxModelFilterSet,
CableTerminationFilterSet,
CabledObjectFilterSet,
PathEndpointFilterSet
):
type = django_filters.MultipleChoiceFilter(
@@ -1201,13 +1212,13 @@ class PowerOutletFilterSet(
class Meta:
model = PowerOutlet
fields = ['id', 'name', 'label', 'feed_leg', 'description']
fields = ['id', 'name', 'label', 'feed_leg', 'description', 'cable_end']
class InterfaceFilterSet(
ModularDeviceComponentFilterSet,
NetBoxModelFilterSet,
CableTerminationFilterSet,
CabledObjectFilterSet,
PathEndpointFilterSet
):
# Override device and device_id filters from DeviceComponentFilterSet to match against any peer virtual chassis
@@ -1247,6 +1258,12 @@ class InterfaceFilterSet(
)
mac_address = MultiValueMACAddressFilter()
wwn = MultiValueWWNFilter()
poe_mode = django_filters.MultipleChoiceFilter(
choices=InterfacePoEModeChoices
)
poe_type = django_filters.MultipleChoiceFilter(
choices=InterfacePoETypeChoices
)
vlan_id = django_filters.CharFilter(
method='filter_vlan_id',
label='Assigned VLAN'
@@ -1280,8 +1297,8 @@ class InterfaceFilterSet(
class Meta:
model = Interface
fields = [
'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'rf_role', 'rf_channel',
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description',
'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'poe_mode', 'poe_type', 'mode', 'rf_role',
'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'cable_end',
]
def filter_device(self, queryset, name, value):
@@ -1335,7 +1352,7 @@ class InterfaceFilterSet(
class FrontPortFilterSet(
ModularDeviceComponentFilterSet,
NetBoxModelFilterSet,
CableTerminationFilterSet
CabledObjectFilterSet
):
type = django_filters.MultipleChoiceFilter(
choices=PortTypeChoices,
@@ -1344,13 +1361,13 @@ class FrontPortFilterSet(
class Meta:
model = FrontPort
fields = ['id', 'name', 'label', 'type', 'color', 'description']
fields = ['id', 'name', 'label', 'type', 'color', 'description', 'cable_end']
class RearPortFilterSet(
ModularDeviceComponentFilterSet,
NetBoxModelFilterSet,
CableTerminationFilterSet
CabledObjectFilterSet
):
type = django_filters.MultipleChoiceFilter(
choices=PortTypeChoices,
@@ -1359,7 +1376,7 @@ class RearPortFilterSet(
class Meta:
model = RearPort
fields = ['id', 'name', 'label', 'type', 'color', 'positions', 'description']
fields = ['id', 'name', 'label', 'type', 'color', 'positions', 'description', 'cable_end']
class ModuleBayFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet):
@@ -1507,10 +1524,18 @@ class VirtualChassisFilterSet(NetBoxModelFilterSet):
class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
termination_a_type = ContentTypeFilter()
termination_a_id = MultiValueNumberFilter()
termination_b_type = ContentTypeFilter()
termination_b_id = MultiValueNumberFilter()
termination_a_type = ContentTypeFilter(
field_name='terminations__termination_type'
)
termination_a_id = MultiValueNumberFilter(
field_name='terminations__termination_id'
)
termination_b_type = ContentTypeFilter(
field_name='terminations__termination_type'
)
termination_b_id = MultiValueNumberFilter(
field_name='terminations__termination_id'
)
type = django_filters.MultipleChoiceFilter(
choices=CableTypeChoices
)
@@ -1521,44 +1546,57 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
choices=ColorChoices
)
device_id = MultiValueNumberFilter(
method='filter_device'
method='filter_by_termination'
)
device = MultiValueCharFilter(
method='filter_device',
method='filter_by_termination',
field_name='device__name'
)
rack_id = MultiValueNumberFilter(
method='filter_device',
field_name='device__rack_id'
method='filter_by_termination',
field_name='rack_id'
)
rack = MultiValueCharFilter(
method='filter_device',
field_name='device__rack__name'
method='filter_by_termination',
field_name='rack__name'
)
location_id = MultiValueNumberFilter(
method='filter_by_termination',
field_name='location_id'
)
location = MultiValueCharFilter(
method='filter_by_termination',
field_name='location__name'
)
site_id = MultiValueNumberFilter(
method='filter_device',
field_name='device__site_id'
method='filter_by_termination',
field_name='site_id'
)
site = MultiValueCharFilter(
method='filter_device',
field_name='device__site__slug'
method='filter_by_termination',
field_name='site__slug'
)
class Meta:
model = Cable
fields = ['id', 'label', 'length', 'length_unit', 'termination_a_id', 'termination_b_id']
fields = ['id', 'label', 'length', 'length_unit']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(label__icontains=value)
def filter_device(self, queryset, name, value):
queryset = queryset.filter(
Q(**{'_termination_a_{}__in'.format(name): value}) |
Q(**{'_termination_b_{}__in'.format(name): value})
)
return queryset
def filter_by_termination(self, queryset, name, value):
# Filter by a related object cached on CableTermination. Note the underscore preceding the field name.
# Supported objects: device, rack, location, site
return queryset.filter(**{f'terminations___{name}__in': value}).distinct()
class CableTerminationFilterSet(BaseFilterSet):
class Meta:
model = CableTermination
fields = ['id', 'cable', 'cable_end', 'termination_type', 'termination_id']
class PowerPanelFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
@@ -1618,7 +1656,7 @@ class PowerPanelFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
return queryset.filter(qs_filter)
class PowerFeedFilterSet(NetBoxModelFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
class PowerFeedFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet, PathEndpointFilterSet):
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='power_panel__site__region',
@@ -1672,7 +1710,9 @@ class PowerFeedFilterSet(NetBoxModelFilterSet, CableTerminationFilterSet, PathEn
class Meta:
model = PowerFeed
fields = ['id', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization']
fields = [
'id', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', 'cable_end',
]
def search(self, queryset, name, value):
if not value.strip():

View File

@@ -72,12 +72,15 @@ class PowerOutletBulkCreateForm(
class InterfaceBulkCreateForm(
form_from_model(Interface, ['type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected']),
form_from_model(Interface, [
'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected', 'poe_mode', 'poe_type',
]),
DeviceBulkAddComponentForm
):
model = Interface
field_order = (
'name_pattern', 'label_pattern', 'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'tags',
'name_pattern', 'label_pattern', 'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'poe_mode',
'poe_type', 'mark_connected', 'description', 'tags',
)

View File

@@ -158,6 +158,12 @@ class LocationBulkEditForm(NetBoxModelBulkEditForm):
'site_id': '$site'
}
)
status = forms.ChoiceField(
choices=add_blank_choice(LocationStatusChoices),
required=False,
initial='',
widget=StaticSelect()
)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
required=False
@@ -169,7 +175,7 @@ class LocationBulkEditForm(NetBoxModelBulkEditForm):
model = Location
fieldsets = (
(None, ('site', 'parent', 'tenant', 'description')),
(None, ('site', 'parent', 'status', 'tenant', 'description')),
)
nullable_fields = ('parent', 'tenant', 'description')
@@ -812,8 +818,22 @@ class InterfaceTemplateBulkEditForm(BulkEditForm):
description = forms.CharField(
required=False
)
poe_mode = forms.ChoiceField(
choices=add_blank_choice(InterfacePoEModeChoices),
required=False,
initial='',
widget=StaticSelect(),
label='PoE mode'
)
poe_type = forms.ChoiceField(
choices=add_blank_choice(InterfacePoETypeChoices),
required=False,
initial='',
widget=StaticSelect(),
label='PoE type'
)
nullable_fields = ('label', 'description')
nullable_fields = ('label', 'description', 'poe_mode', 'poe_type')
class FrontPortTemplateBulkEditForm(BulkEditForm):
@@ -1063,6 +1083,20 @@ class InterfaceBulkEditForm(
widget=BulkEditNullBooleanSelect,
label='Management only'
)
poe_mode = forms.ChoiceField(
choices=add_blank_choice(InterfacePoEModeChoices),
required=False,
initial='',
widget=StaticSelect(),
label='PoE mode'
)
poe_type = forms.ChoiceField(
choices=add_blank_choice(InterfacePoETypeChoices),
required=False,
initial='',
widget=StaticSelect(),
label='PoE type'
)
mark_connected = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect
@@ -1105,14 +1139,15 @@ class InterfaceBulkEditForm(
(None, ('module', 'type', 'label', 'speed', 'duplex', 'description')),
('Addressing', ('vrf', 'mac_address', 'wwn')),
('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
('PoE', ('poe_mode', 'poe_type')),
('Related Interfaces', ('parent', 'bridge', 'lag')),
('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')),
('Wireless', ('rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width')),
)
nullable_fields = (
'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'description',
'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'vlan_group', 'untagged_vlan',
'tagged_vlans', 'vrf',
'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf',
)
def __init__(self, *args, **kwargs):

View File

@@ -124,6 +124,10 @@ class LocationCSVForm(NetBoxModelCSVForm):
'invalid_choice': 'Location not found.',
}
)
status = CSVChoiceField(
choices=LocationStatusChoices,
help_text='Operational status'
)
tenant = CSVModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
@@ -133,7 +137,7 @@ class LocationCSVForm(NetBoxModelCSVForm):
class Meta:
model = Location
fields = ('site', 'parent', 'name', 'slug', 'tenant', 'description')
fields = ('site', 'parent', 'name', 'slug', 'status', 'tenant', 'description')
class RackRoleCSVForm(NetBoxModelCSVForm):
@@ -622,6 +626,16 @@ class InterfaceCSVForm(NetBoxModelCSVForm):
choices=InterfaceDuplexChoices,
required=False
)
poe_mode = CSVChoiceField(
choices=InterfacePoEModeChoices,
required=False,
help_text='PoE mode'
)
poe_type = CSVChoiceField(
choices=InterfacePoETypeChoices,
required=False,
help_text='PoE type'
)
mode = CSVChoiceField(
choices=InterfaceModeChoices,
required=False,
@@ -642,9 +656,9 @@ class InterfaceCSVForm(NetBoxModelCSVForm):
class Meta:
model = Interface
fields = (
'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled', 'mark_connected', 'mac_address',
'wwn', 'mtu', 'mgmt_only', 'description', 'mode', 'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency',
'rf_channel_width', 'tx_power',
'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled',
'mark_connected', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'description', 'poe_mode', 'poe_type', 'mode',
'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
)
def __init__(self, data=None, *args, **kwargs):
@@ -941,7 +955,7 @@ class CableCSVForm(NetBoxModelCSVForm):
except ObjectDoesNotExist:
raise forms.ValidationError(f"{side.upper()} side termination not found: {device} {name}")
setattr(self.instance, f'termination_{side}', termination_object)
setattr(self.instance, f'{side}_terminations', [termination_object])
return termination_object
def clean_side_a_name(self):

View File

@@ -1,279 +1,170 @@
from django import forms
from circuits.models import Circuit, CircuitTermination, Provider
from dcim.models import *
from extras.models import Tag
from netbox.forms import NetBoxModelForm
from tenancy.forms import TenancyForm
from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect
__all__ = (
'ConnectCableToCircuitTerminationForm',
'ConnectCableToConsolePortForm',
'ConnectCableToConsoleServerPortForm',
'ConnectCableToFrontPortForm',
'ConnectCableToInterfaceForm',
'ConnectCableToPowerFeedForm',
'ConnectCableToPowerPortForm',
'ConnectCableToPowerOutletForm',
'ConnectCableToRearPortForm',
)
from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField
from .models import CableForm
class ConnectCableToDeviceForm(TenancyForm, NetBoxModelForm):
"""
Base form for connecting a Cable to a Device component
"""
termination_b_region = DynamicModelChoiceField(
queryset=Region.objects.all(),
label='Region',
required=False
)
termination_b_sitegroup = DynamicModelChoiceField(
queryset=SiteGroup.objects.all(),
label='Site group',
required=False
)
termination_b_site = DynamicModelChoiceField(
queryset=Site.objects.all(),
label='Site',
required=False,
query_params={
'region_id': '$termination_b_region',
'group_id': '$termination_b_sitegroup',
}
)
termination_b_location = DynamicModelChoiceField(
queryset=Location.objects.all(),
label='Location',
required=False,
null_option='None',
query_params={
'site_id': '$termination_b_site'
}
)
termination_b_rack = DynamicModelChoiceField(
queryset=Rack.objects.all(),
label='Rack',
required=False,
null_option='None',
query_params={
'site_id': '$termination_b_site',
'location_id': '$termination_b_location',
}
)
termination_b_device = DynamicModelChoiceField(
queryset=Device.objects.all(),
label='Device',
required=False,
query_params={
'site_id': '$termination_b_site',
'location_id': '$termination_b_location',
'rack_id': '$termination_b_rack',
}
)
def get_cable_form(a_type, b_type):
class Meta:
model = Cable
fields = [
'termination_b_region', 'termination_b_sitegroup', 'termination_b_site', 'termination_b_rack',
'termination_b_device', 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color',
'length', 'length_unit', 'tags',
]
widgets = {
'status': StaticSelect,
'type': StaticSelect,
'length_unit': StaticSelect,
}
class FormMetaclass(forms.models.ModelFormMetaclass):
def clean_termination_b_id(self):
# Return the PK rather than the object
return getattr(self.cleaned_data['termination_b_id'], 'pk', None)
def __new__(mcs, name, bases, attrs):
for cable_end, term_cls in (('a', a_type), ('b', b_type)):
class ConnectCableToConsolePortForm(ConnectCableToDeviceForm):
termination_b_id = DynamicModelChoiceField(
queryset=ConsolePort.objects.all(),
label='Name',
disabled_indicator='_occupied',
query_params={
'device_id': '$termination_b_device'
}
)
attrs[f'termination_{cable_end}_region'] = DynamicModelChoiceField(
queryset=Region.objects.all(),
label='Region',
required=False,
initial_params={
'sites': f'$termination_{cable_end}_site'
}
)
attrs[f'termination_{cable_end}_sitegroup'] = DynamicModelChoiceField(
queryset=SiteGroup.objects.all(),
label='Site group',
required=False,
initial_params={
'sites': f'$termination_{cable_end}_site'
}
)
attrs[f'termination_{cable_end}_site'] = DynamicModelChoiceField(
queryset=Site.objects.all(),
label='Site',
required=False,
query_params={
'region_id': f'$termination_{cable_end}_region',
'group_id': f'$termination_{cable_end}_sitegroup',
}
)
attrs[f'termination_{cable_end}_location'] = DynamicModelChoiceField(
queryset=Location.objects.all(),
label='Location',
required=False,
null_option='None',
query_params={
'site_id': f'$termination_{cable_end}_site'
}
)
# Device component
if hasattr(term_cls, 'device'):
class ConnectCableToConsoleServerPortForm(ConnectCableToDeviceForm):
termination_b_id = DynamicModelChoiceField(
queryset=ConsoleServerPort.objects.all(),
label='Name',
disabled_indicator='_occupied',
query_params={
'device_id': '$termination_b_device'
}
)
attrs[f'termination_{cable_end}_rack'] = DynamicModelChoiceField(
queryset=Rack.objects.all(),
label='Rack',
required=False,
null_option='None',
initial_params={
'devices': f'$termination_{cable_end}_device'
},
query_params={
'site_id': f'$termination_{cable_end}_site',
'location_id': f'$termination_{cable_end}_location',
}
)
attrs[f'termination_{cable_end}_device'] = DynamicModelChoiceField(
queryset=Device.objects.all(),
label='Device',
required=False,
initial_params={
f'{term_cls._meta.model_name}s__in': f'${cable_end}_terminations'
},
query_params={
'site_id': f'$termination_{cable_end}_site',
'location_id': f'$termination_{cable_end}_location',
'rack_id': f'$termination_{cable_end}_rack',
}
)
attrs[f'{cable_end}_terminations'] = DynamicModelMultipleChoiceField(
queryset=term_cls.objects.all(),
label=term_cls._meta.verbose_name.title(),
disabled_indicator='_occupied',
query_params={
'device_id': f'$termination_{cable_end}_device',
}
)
# PowerFeed
elif term_cls == PowerFeed:
class ConnectCableToPowerPortForm(ConnectCableToDeviceForm):
termination_b_id = DynamicModelChoiceField(
queryset=PowerPort.objects.all(),
label='Name',
disabled_indicator='_occupied',
query_params={
'device_id': '$termination_b_device'
}
)
attrs[f'termination_{cable_end}_powerpanel'] = DynamicModelChoiceField(
queryset=PowerPanel.objects.all(),
label='Power Panel',
required=False,
initial_params={
'powerfeeds__in': f'${cable_end}_terminations'
},
query_params={
'site_id': f'$termination_{cable_end}_site',
'location_id': f'$termination_{cable_end}_location',
}
)
attrs[f'{cable_end}_terminations'] = DynamicModelMultipleChoiceField(
queryset=term_cls.objects.all(),
label='Power Feed',
disabled_indicator='_occupied',
query_params={
'powerpanel_id': f'$termination_{cable_end}_powerpanel',
}
)
# CircuitTermination
elif term_cls == CircuitTermination:
class ConnectCableToPowerOutletForm(ConnectCableToDeviceForm):
termination_b_id = DynamicModelChoiceField(
queryset=PowerOutlet.objects.all(),
label='Name',
disabled_indicator='_occupied',
query_params={
'device_id': '$termination_b_device'
}
)
attrs[f'termination_{cable_end}_provider'] = DynamicModelChoiceField(
queryset=Provider.objects.all(),
label='Provider',
initial_params={
'circuits': f'$termination_{cable_end}_circuit'
},
required=False
)
attrs[f'termination_{cable_end}_circuit'] = DynamicModelChoiceField(
queryset=Circuit.objects.all(),
label='Circuit',
initial_params={
'terminations__in': f'${cable_end}_terminations'
},
query_params={
'provider_id': f'$termination_{cable_end}_provider',
'site_id': f'$termination_{cable_end}_site',
}
)
attrs[f'{cable_end}_terminations'] = DynamicModelMultipleChoiceField(
queryset=term_cls.objects.all(),
label='Side',
disabled_indicator='_occupied',
query_params={
'circuit_id': f'$termination_{cable_end}_circuit',
}
)
return super().__new__(mcs, name, bases, attrs)
class ConnectCableToInterfaceForm(ConnectCableToDeviceForm):
termination_b_id = DynamicModelChoiceField(
queryset=Interface.objects.all(),
label='Name',
disabled_indicator='_occupied',
query_params={
'device_id': '$termination_b_device',
'kind': 'physical',
}
)
class _CableForm(CableForm, metaclass=FormMetaclass):
def __init__(self, *args, **kwargs):
class ConnectCableToFrontPortForm(ConnectCableToDeviceForm):
termination_b_id = DynamicModelChoiceField(
queryset=FrontPort.objects.all(),
label='Name',
disabled_indicator='_occupied',
query_params={
'device_id': '$termination_b_device'
}
)
# TODO: Temporary hack to work around list handling limitations with utils.normalize_querydict()
for field_name in ('a_terminations', 'b_terminations'):
if field_name in kwargs.get('initial', {}) and type(kwargs['initial'][field_name]) is not list:
kwargs['initial'][field_name] = [kwargs['initial'][field_name]]
super().__init__(*args, **kwargs)
class ConnectCableToRearPortForm(ConnectCableToDeviceForm):
termination_b_id = DynamicModelChoiceField(
queryset=RearPort.objects.all(),
label='Name',
disabled_indicator='_occupied',
query_params={
'device_id': '$termination_b_device'
}
)
if self.instance and self.instance.pk:
# Initialize A/B terminations when modifying an existing Cable instance
self.initial['a_terminations'] = self.instance.a_terminations
self.initial['b_terminations'] = self.instance.b_terminations
def clean(self):
super().clean()
class ConnectCableToCircuitTerminationForm(TenancyForm, NetBoxModelForm):
termination_b_provider = DynamicModelChoiceField(
queryset=Provider.objects.all(),
label='Provider',
required=False
)
termination_b_region = DynamicModelChoiceField(
queryset=Region.objects.all(),
label='Region',
required=False
)
termination_b_sitegroup = DynamicModelChoiceField(
queryset=SiteGroup.objects.all(),
label='Site group',
required=False
)
termination_b_site = DynamicModelChoiceField(
queryset=Site.objects.all(),
label='Site',
required=False,
query_params={
'region_id': '$termination_b_region',
'group_id': '$termination_b_sitegroup',
}
)
termination_b_circuit = DynamicModelChoiceField(
queryset=Circuit.objects.all(),
label='Circuit',
query_params={
'provider_id': '$termination_b_provider',
'site_id': '$termination_b_site',
}
)
termination_b_id = DynamicModelChoiceField(
queryset=CircuitTermination.objects.all(),
label='Side',
disabled_indicator='_occupied',
query_params={
'circuit_id': '$termination_b_circuit'
}
)
# Set the A/B terminations on the Cable instance
self.instance.a_terminations = self.cleaned_data['a_terminations']
self.instance.b_terminations = self.cleaned_data['b_terminations']
class Meta(ConnectCableToDeviceForm.Meta):
fields = [
'termination_b_provider', 'termination_b_region', 'termination_b_sitegroup', 'termination_b_site',
'termination_b_circuit', 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color',
'length', 'length_unit', 'tags',
]
def clean_termination_b_id(self):
# Return the PK rather than the object
return getattr(self.cleaned_data['termination_b_id'], 'pk', None)
class ConnectCableToPowerFeedForm(TenancyForm, NetBoxModelForm):
termination_b_region = DynamicModelChoiceField(
queryset=Region.objects.all(),
label='Region',
required=False
)
termination_b_sitegroup = DynamicModelChoiceField(
queryset=SiteGroup.objects.all(),
label='Site group',
required=False
)
termination_b_site = DynamicModelChoiceField(
queryset=Site.objects.all(),
label='Site',
required=False,
query_params={
'region_id': '$termination_b_region',
'group_id': '$termination_b_sitegroup',
}
)
termination_b_location = DynamicModelChoiceField(
queryset=Location.objects.all(),
label='Location',
required=False,
query_params={
'site_id': '$termination_b_site'
}
)
termination_b_powerpanel = DynamicModelChoiceField(
queryset=PowerPanel.objects.all(),
label='Power Panel',
required=False,
query_params={
'site_id': '$termination_b_site',
'location_id': '$termination_b_location',
}
)
termination_b_id = DynamicModelChoiceField(
queryset=PowerFeed.objects.all(),
label='Name',
disabled_indicator='_occupied',
query_params={
'power_panel_id': '$termination_b_powerpanel'
}
)
class Meta(ConnectCableToDeviceForm.Meta):
fields = [
'termination_b_region', 'termination_b_sitegroup', 'termination_b_site', 'termination_b_location',
'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label',
'color', 'length', 'length_unit', 'tags',
]
def clean_termination_b_id(self):
# Return the PK rather than the object
return getattr(self.cleaned_data['termination_b_id'], 'pk', None)
return _CableForm

View File

@@ -166,7 +166,7 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelF
model = Location
fieldsets = (
(None, ('q', 'tag')),
('Parent', ('region_id', 'site_group_id', 'site_id', 'parent_id')),
('Attributes', ('region_id', 'site_group_id', 'site_id', 'parent_id', 'status')),
('Tenant', ('tenant_group_id', 'tenant_id')),
('Contacts', ('contact', 'contact_role', 'contact_group')),
)
@@ -198,6 +198,10 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelF
},
label=_('Parent')
)
status = MultipleChoiceField(
choices=LocationStatusChoices,
required=False
)
tag = TagFilterField(model)
@@ -287,7 +291,7 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
fieldsets = (
(None, ('q', 'tag')),
('User', ('user_id',)),
('Rack', ('region_id', 'site_group_id', 'site_id', 'location_id')),
('Rack', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
('Tenant', ('tenant_group_id', 'tenant_id')),
)
region_id = DynamicModelMultipleChoiceField(
@@ -295,25 +299,38 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
required=False,
label=_('Region')
)
site_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
required=False,
query_params={
'region_id': '$region_id'
},
label=_('Site')
)
site_group_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
label=_('Site group')
)
location_id = DynamicModelMultipleChoiceField(
queryset=Location.objects.prefetch_related('site'),
site_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
required=False,
query_params={
'region_id': '$region_id',
'group_id': '$site_group_id',
},
label=_('Site')
)
location_id = DynamicModelMultipleChoiceField(
queryset=Location.objects.all(),
required=False,
query_params={
'site_id': '$site_id',
},
label=_('Location'),
null_option='None'
)
rack_id = DynamicModelMultipleChoiceField(
queryset=Rack.objects.all(),
required=False,
query_params={
'site_id': '$site_id',
'location_id': '$location_id',
},
label=_('Rack')
)
user_id = DynamicModelMultipleChoiceField(
queryset=User.objects.all(),
required=False,
@@ -726,7 +743,7 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
model = Cable
fieldsets = (
(None, ('q', 'tag')),
('Location', ('site_id', 'rack_id', 'device_id')),
('Location', ('site_id', 'location_id', 'rack_id', 'device_id')),
('Attributes', ('type', 'status', 'color', 'length', 'length_unit')),
('Tenant', ('tenant_group_id', 'tenant_id')),
)
@@ -743,13 +760,23 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
},
label=_('Site')
)
location_id = DynamicModelMultipleChoiceField(
queryset=Location.objects.all(),
required=False,
label=_('Location'),
null_option='None',
query_params={
'site_id': '$site_id'
}
)
rack_id = DynamicModelMultipleChoiceField(
queryset=Rack.objects.all(),
required=False,
label=_('Rack'),
null_option='None',
query_params={
'site_id': '$site_id'
'site_id': '$site_id',
'location_id': '$location_id',
}
)
device_id = DynamicModelMultipleChoiceField(
@@ -757,8 +784,9 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
required=False,
query_params={
'site_id': '$site_id',
'tenant_id': '$tenant_id',
'location_id': '$location_id',
'rack_id': '$rack_id',
'tenant_id': '$tenant_id',
},
label=_('Device')
)
@@ -969,6 +997,7 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
(None, ('q', 'tag')),
('Attributes', ('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only')),
('Addressing', ('vrf_id', 'mac_address', 'wwn')),
('PoE', ('poe_mode', 'poe_type')),
('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')),
)
@@ -1009,6 +1038,16 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
required=False,
label='WWN'
)
poe_mode = MultipleChoiceField(
choices=InterfacePoEModeChoices,
required=False,
label='PoE mode'
)
poe_type = MultipleChoiceField(
choices=InterfacePoEModeChoices,
required=False,
label='PoE type'
)
rf_role = MultipleChoiceField(
choices=WirelessRoleChoices,
required=False,

View File

@@ -194,7 +194,7 @@ class LocationForm(TenancyForm, NetBoxModelForm):
fieldsets = (
('Location', (
'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tags',
'region', 'site_group', 'site', 'parent', 'name', 'slug', 'status', 'description', 'tags',
)),
('Tenancy', ('tenant_group', 'tenant')),
)
@@ -202,8 +202,12 @@ class LocationForm(TenancyForm, NetBoxModelForm):
class Meta:
model = Location
fields = (
'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tenant_group', 'tenant', 'tags',
'region', 'site_group', 'site', 'parent', 'name', 'slug', 'status', 'description', 'tenant_group', 'tenant',
'tags',
)
widgets = {
'status': StaticSelect(),
}
class RackRoleForm(NetBoxModelForm):
@@ -321,7 +325,7 @@ class RackReservationForm(TenancyForm, NetBoxModelForm):
)
fieldsets = (
('Reservation', ('region', 'site', 'location', 'rack', 'units', 'user', 'description', 'tags')),
('Reservation', ('region', 'site_group', 'site', 'location', 'rack', 'units', 'user', 'description', 'tags')),
('Tenancy', ('tenant_group', 'tenant')),
)
@@ -467,7 +471,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
'location_id': '$location',
}
)
position = forms.IntegerField(
position = forms.DecimalField(
required=False,
help_text="The lowest-numbered unit occupied by the device",
widget=APISelect(
@@ -1048,12 +1052,14 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = InterfaceTemplate
fields = [
'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description',
'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description', 'poe_mode', 'poe_type',
]
widgets = {
'device_type': forms.HiddenInput(),
'module_type': forms.HiddenInput(),
'type': StaticSelect(),
'poe_mode': StaticSelect(),
'poe_type': StaticSelect(),
}
@@ -1329,6 +1335,7 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm):
('Addressing', ('vrf', 'mac_address', 'wwn')),
('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
('Related Interfaces', ('parent', 'bridge', 'lag')),
('PoE', ('poe_mode', 'poe_type')),
('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')),
('Wireless', (
'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans',
@@ -1339,14 +1346,16 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm):
model = Interface
fields = [
'device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'enabled', 'parent', 'bridge', 'lag',
'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel',
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'wireless_lans', 'untagged_vlan', 'tagged_vlans',
'vrf', 'tags',
'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'poe_mode', 'poe_type', 'mode',
'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'wireless_lans',
'untagged_vlan', 'tagged_vlans', 'vrf', 'tags',
]
widgets = {
'device': forms.HiddenInput(),
'type': StaticSelect(),
'speed': SelectSpeedWidget(),
'poe_mode': StaticSelect(),
'poe_type': StaticSelect(),
'duplex': StaticSelect(),
'mode': StaticSelect(),
'rf_role': StaticSelect(),

View File

@@ -64,6 +64,14 @@ class ModularComponentTemplateCreateForm(ComponentCreateForm):
"""
Creation form for component templates that can be assigned to either a DeviceType *or* a ModuleType.
"""
name_pattern = ExpandableNameField(
label='Name',
help_text="""
Alphanumeric ranges are supported for bulk creation. Mixed cases and types within a single range
are not supported. Example: <code>[ge,xe]-0/0/[0-9]</code>. {module} is accepted as a substitution for
the module bay position.
"""
)
device_type = DynamicModelChoiceField(
queryset=DeviceType.objects.all(),
required=False

View File

@@ -1,6 +1,6 @@
from django import forms
from dcim.choices import InterfaceTypeChoices, PortTypeChoices
from dcim.choices import InterfacePoEModeChoices, InterfacePoETypeChoices, InterfaceTypeChoices, PortTypeChoices
from dcim.models import *
from utilities.forms import BootstrapMixin
@@ -112,11 +112,21 @@ class InterfaceTemplateImportForm(ComponentTemplateImportForm):
type = forms.ChoiceField(
choices=InterfaceTypeChoices.CHOICES
)
poe_mode = forms.ChoiceField(
choices=InterfacePoEModeChoices,
required=False,
label='PoE mode'
)
poe_type = forms.ChoiceField(
choices=InterfacePoETypeChoices,
required=False,
label='PoE type'
)
class Meta:
model = InterfaceTemplate
fields = [
'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description',
'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description', 'poe_mode', 'poe_type',
]

View File

@@ -0,0 +1,5 @@
class CabledObjectMixin:
def resolve_cable_end(self, info):
# Handle empty values
return self.cable_end or None

View File

@@ -7,6 +7,7 @@ from extras.graphql.mixins import (
from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin
from netbox.graphql.scalars import BigInt
from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType
from .mixins import CabledObjectMixin
__all__ = (
'CableType',
@@ -99,7 +100,15 @@ class CableType(NetBoxObjectType):
return self.length_unit or None
class ConsolePortType(ComponentObjectType):
class CableTerminationType(NetBoxObjectType):
class Meta:
model = models.CableTermination
fields = '__all__'
filterset_class = filtersets.CableTerminationFilterSet
class ConsolePortType(ComponentObjectType, CabledObjectMixin):
class Meta:
model = models.ConsolePort
@@ -121,7 +130,7 @@ class ConsolePortTemplateType(ComponentTemplateObjectType):
return self.type or None
class ConsoleServerPortType(ComponentObjectType):
class ConsoleServerPortType(ComponentObjectType, CabledObjectMixin):
class Meta:
model = models.ConsoleServerPort
@@ -203,7 +212,7 @@ class DeviceTypeType(NetBoxObjectType):
return self.airflow or None
class FrontPortType(ComponentObjectType):
class FrontPortType(ComponentObjectType, CabledObjectMixin):
class Meta:
model = models.FrontPort
@@ -219,13 +228,19 @@ class FrontPortTemplateType(ComponentTemplateObjectType):
filterset_class = filtersets.FrontPortTemplateFilterSet
class InterfaceType(IPAddressesMixin, ComponentObjectType):
class InterfaceType(IPAddressesMixin, ComponentObjectType, CabledObjectMixin):
class Meta:
model = models.Interface
exclude = ('_path',)
filterset_class = filtersets.InterfaceFilterSet
def resolve_poe_mode(self, info):
return self.poe_mode or None
def resolve_poe_type(self, info):
return self.poe_type or None
def resolve_mode(self, info):
return self.mode or None
@@ -243,6 +258,12 @@ class InterfaceTemplateType(ComponentTemplateObjectType):
fields = '__all__'
filterset_class = filtersets.InterfaceTemplateFilterSet
def resolve_poe_mode(self, info):
return self.poe_mode or None
def resolve_poe_type(self, info):
return self.poe_type or None
class InventoryItemType(ComponentObjectType):
@@ -316,7 +337,7 @@ class PlatformType(OrganizationalObjectType):
filterset_class = filtersets.PlatformFilterSet
class PowerFeedType(NetBoxObjectType):
class PowerFeedType(NetBoxObjectType, CabledObjectMixin):
class Meta:
model = models.PowerFeed
@@ -324,7 +345,7 @@ class PowerFeedType(NetBoxObjectType):
filterset_class = filtersets.PowerFeedFilterSet
class PowerOutletType(ComponentObjectType):
class PowerOutletType(ComponentObjectType, CabledObjectMixin):
class Meta:
model = models.PowerOutlet
@@ -360,7 +381,7 @@ class PowerPanelType(NetBoxObjectType):
filterset_class = filtersets.PowerPanelFilterSet
class PowerPortType(ComponentObjectType):
class PowerPortType(ComponentObjectType, CabledObjectMixin):
class Meta:
model = models.PowerPort
@@ -412,7 +433,7 @@ class RackRoleType(OrganizationalObjectType):
filterset_class = filtersets.RackRoleFilterSet
class RearPortType(ComponentObjectType):
class RearPortType(ComponentObjectType, CabledObjectMixin):
class Meta:
model = models.RearPort

View File

@@ -81,7 +81,7 @@ class Command(BaseCommand):
self.stdout.write(f'Retracing {origins_count} cabled {model._meta.verbose_name_plural}...')
i = 0
for i, obj in enumerate(origins, start=1):
create_cablepath(obj)
create_cablepath([obj])
if not i % 100:
self.draw_progress_bar(i * 100 / origins_count)
self.draw_progress_bar(100)

View File

@@ -0,0 +1,23 @@
import django.contrib.postgres.fields
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0153_created_datetimefield'),
]
operations = [
migrations.AlterField(
model_name='devicetype',
name='u_height',
field=models.DecimalField(decimal_places=1, default=1.0, max_digits=4),
),
migrations.AlterField(
model_name='device',
name='position',
field=models.DecimalField(blank=True, decimal_places=1, max_digits=4, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(99.5)]),
),
]

View File

@@ -0,0 +1,33 @@
# Generated by Django 4.0.5 on 2022-06-22 00:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0154_half_height_rack_units'),
]
operations = [
migrations.AddField(
model_name='interface',
name='poe_mode',
field=models.CharField(blank=True, max_length=50),
),
migrations.AddField(
model_name='interface',
name='poe_type',
field=models.CharField(blank=True, max_length=50),
),
migrations.AddField(
model_name='interfacetemplate',
name='poe_mode',
field=models.CharField(blank=True, max_length=50),
),
migrations.AddField(
model_name='interfacetemplate',
name='poe_type',
field=models.CharField(blank=True, max_length=50),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.0.5 on 2022-06-22 17:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0155_interface_poe_mode_type'),
]
operations = [
migrations.AddField(
model_name='location',
name='status',
field=models.CharField(default='active', max_length=50),
),
]

View File

@@ -0,0 +1,95 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('dcim', '0156_location_status'),
]
operations = [
# Create CableTermination model
migrations.CreateModel(
name='CableTermination',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('cable_end', models.CharField(max_length=1)),
('termination_id', models.PositiveBigIntegerField()),
('cable', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='terminations', to='dcim.cable')),
('termination_type', models.ForeignKey(limit_choices_to=models.Q(models.Q(models.Q(('app_label', 'circuits'), ('model__in', ('circuittermination',))), models.Q(('app_label', 'dcim'), ('model__in', ('consoleport', 'consoleserverport', 'frontport', 'interface', 'powerfeed', 'poweroutlet', 'powerport', 'rearport'))), _connector='OR')), on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')),
('_device', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.device')),
('_rack', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.rack')),
('_location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.location')),
('_site', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.site')),
],
options={
'ordering': ('cable', 'cable_end', 'pk'),
},
),
migrations.AddConstraint(
model_name='cabletermination',
constraint=models.UniqueConstraint(fields=('termination_type', 'termination_id'), name='dcim_cable_termination_unique_termination'),
),
# Update CablePath model
migrations.RenameField(
model_name='cablepath',
old_name='path',
new_name='_nodes',
),
migrations.AddField(
model_name='cablepath',
name='path',
field=models.JSONField(default=list),
),
migrations.AddField(
model_name='cablepath',
name='is_complete',
field=models.BooleanField(default=False),
),
# Add cable_end field to cable termination models
migrations.AddField(
model_name='consoleport',
name='cable_end',
field=models.CharField(blank=True, max_length=1),
),
migrations.AddField(
model_name='consoleserverport',
name='cable_end',
field=models.CharField(blank=True, max_length=1),
),
migrations.AddField(
model_name='frontport',
name='cable_end',
field=models.CharField(blank=True, max_length=1),
),
migrations.AddField(
model_name='interface',
name='cable_end',
field=models.CharField(blank=True, max_length=1),
),
migrations.AddField(
model_name='powerfeed',
name='cable_end',
field=models.CharField(blank=True, max_length=1),
),
migrations.AddField(
model_name='poweroutlet',
name='cable_end',
field=models.CharField(blank=True, max_length=1),
),
migrations.AddField(
model_name='powerport',
name='cable_end',
field=models.CharField(blank=True, max_length=1),
),
migrations.AddField(
model_name='rearport',
name='cable_end',
field=models.CharField(blank=True, max_length=1),
),
]

View File

@@ -0,0 +1,87 @@
import sys
from django.db import migrations
def cache_related_objects(termination):
"""
Replicate caching logic from CableTermination.cache_related_objects()
"""
attrs = {}
# Device components
if getattr(termination, 'device', None):
attrs['_device'] = termination.device
attrs['_rack'] = termination.device.rack
attrs['_location'] = termination.device.location
attrs['_site'] = termination.device.site
# Power feeds
elif getattr(termination, 'rack', None):
attrs['_rack'] = termination.rack
attrs['_location'] = termination.rack.location
attrs['_site'] = termination.rack.site
# Circuit terminations
elif getattr(termination, 'site', None):
attrs['_site'] = termination.site
return attrs
def populate_cable_terminations(apps, schema_editor):
"""
Replicate terminations from the Cable model into CableTermination instances.
"""
ContentType = apps.get_model('contenttypes', 'ContentType')
Cable = apps.get_model('dcim', 'Cable')
CableTermination = apps.get_model('dcim', 'CableTermination')
# Retrieve the necessary data from Cable objects
cables = Cable.objects.values(
'id', 'termination_a_type', 'termination_a_id', 'termination_b_type', 'termination_b_id'
)
# Queue CableTerminations to be created
cable_terminations = []
cable_count = cables.count()
for i, cable in enumerate(cables, start=1):
for cable_end in ('a', 'b'):
# We must manually instantiate the termination object, because GFK fields are not
# supported within migrations.
termination_ct = ContentType.objects.get(pk=cable[f'termination_{cable_end}_type'])
termination_model = apps.get_model(termination_ct.app_label, termination_ct.model)
termination = termination_model.objects.get(pk=cable[f'termination_{cable_end}_id'])
cable_terminations.append(CableTermination(
cable_id=cable['id'],
cable_end=cable_end.upper(),
termination_type_id=cable[f'termination_{cable_end}_type'],
termination_id=cable[f'termination_{cable_end}_id'],
**cache_related_objects(termination)
))
# Output progress occasionally
if 'test' not in sys.argv and not i % 100:
progress = float(i) * 100 / cable_count
if i == 100:
print('')
sys.stdout.write(f"\r Updated {i}/{cable_count} cables ({progress:.2f}%)")
sys.stdout.flush()
# Bulk create the termination objects
CableTermination.objects.bulk_create(cable_terminations, batch_size=100)
class Migration(migrations.Migration):
dependencies = [
('dcim', '0157_new_cabling_models'),
]
operations = [
migrations.RunPython(
code=populate_cable_terminations,
reverse_code=migrations.RunPython.noop
),
]

View File

@@ -0,0 +1,50 @@
from django.db import migrations
from dcim.utils import compile_path_node
def populate_cable_paths(apps, schema_editor):
"""
Replicate terminations from the Cable model into CableTermination instances.
"""
CablePath = apps.get_model('dcim', 'CablePath')
# Construct the new two-dimensional path, and add the origin & destination objects to the nodes list
cable_paths = []
for cablepath in CablePath.objects.all():
# Origin
origin = compile_path_node(cablepath.origin_type_id, cablepath.origin_id)
cablepath.path.append([origin])
cablepath._nodes.insert(0, origin)
# Transit nodes
cablepath.path.extend([
[node] for node in cablepath._nodes[1:]
])
# Destination
if cablepath.destination_id:
destination = compile_path_node(cablepath.destination_type_id, cablepath.destination_id)
cablepath.path.append([destination])
cablepath._nodes.append(destination)
cablepath.is_complete = True
cable_paths.append(cablepath)
# Bulk update all CableTerminations
CablePath.objects.bulk_update(cable_paths, fields=('path', '_nodes', 'is_complete'), batch_size=100)
class Migration(migrations.Migration):
dependencies = [
('dcim', '0158_populate_cable_terminations'),
]
operations = [
migrations.RunPython(
code=populate_cable_paths,
reverse_code=migrations.RunPython.noop
),
]

View File

@@ -0,0 +1,46 @@
from django.db import migrations
def populate_cable_terminations(apps, schema_editor):
Cable = apps.get_model('dcim', 'Cable')
cable_termination_models = (
apps.get_model('dcim', 'ConsolePort'),
apps.get_model('dcim', 'ConsoleServerPort'),
apps.get_model('dcim', 'PowerPort'),
apps.get_model('dcim', 'PowerOutlet'),
apps.get_model('dcim', 'Interface'),
apps.get_model('dcim', 'FrontPort'),
apps.get_model('dcim', 'RearPort'),
apps.get_model('dcim', 'PowerFeed'),
apps.get_model('circuits', 'CircuitTermination'),
)
for model in cable_termination_models:
model.objects.filter(
id__in=Cable.objects.filter(
termination_a_type__app_label=model._meta.app_label,
termination_a_type__model=model._meta.model_name
).values_list('termination_a_id', flat=True)
).update(cable_end='A')
model.objects.filter(
id__in=Cable.objects.filter(
termination_b_type__app_label=model._meta.app_label,
termination_b_type__model=model._meta.model_name
).values_list('termination_b_id', flat=True)
).update(cable_end='B')
class Migration(migrations.Migration):
dependencies = [
('circuits', '0037_new_cabling_models'),
('dcim', '0159_populate_cable_paths'),
]
operations = [
migrations.RunPython(
code=populate_cable_terminations,
reverse_code=migrations.RunPython.noop
),
]

View File

@@ -0,0 +1,134 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('dcim', '0160_populate_cable_ends'),
]
operations = [
# Remove old fields from Cable
migrations.AlterModelOptions(
name='cable',
options={'ordering': ('pk',)},
),
migrations.AlterUniqueTogether(
name='cable',
unique_together=set(),
),
migrations.RemoveField(
model_name='cable',
name='termination_a_id',
),
migrations.RemoveField(
model_name='cable',
name='termination_a_type',
),
migrations.RemoveField(
model_name='cable',
name='termination_b_id',
),
migrations.RemoveField(
model_name='cable',
name='termination_b_type',
),
migrations.RemoveField(
model_name='cable',
name='_termination_a_device',
),
migrations.RemoveField(
model_name='cable',
name='_termination_b_device',
),
# Remove old fields from CablePath
migrations.AlterUniqueTogether(
name='cablepath',
unique_together=set(),
),
migrations.RemoveField(
model_name='cablepath',
name='destination_id',
),
migrations.RemoveField(
model_name='cablepath',
name='destination_type',
),
migrations.RemoveField(
model_name='cablepath',
name='origin_id',
),
migrations.RemoveField(
model_name='cablepath',
name='origin_type',
),
# Remove link peer type/ID fields from cable termination models
migrations.RemoveField(
model_name='consoleport',
name='_link_peer_id',
),
migrations.RemoveField(
model_name='consoleport',
name='_link_peer_type',
),
migrations.RemoveField(
model_name='consoleserverport',
name='_link_peer_id',
),
migrations.RemoveField(
model_name='consoleserverport',
name='_link_peer_type',
),
migrations.RemoveField(
model_name='frontport',
name='_link_peer_id',
),
migrations.RemoveField(
model_name='frontport',
name='_link_peer_type',
),
migrations.RemoveField(
model_name='interface',
name='_link_peer_id',
),
migrations.RemoveField(
model_name='interface',
name='_link_peer_type',
),
migrations.RemoveField(
model_name='powerfeed',
name='_link_peer_id',
),
migrations.RemoveField(
model_name='powerfeed',
name='_link_peer_type',
),
migrations.RemoveField(
model_name='poweroutlet',
name='_link_peer_id',
),
migrations.RemoveField(
model_name='poweroutlet',
name='_link_peer_type',
),
migrations.RemoveField(
model_name='powerport',
name='_link_peer_id',
),
migrations.RemoveField(
model_name='powerport',
name='_link_peer_type',
),
migrations.RemoveField(
model_name='rearport',
name='_link_peer_id',
),
migrations.RemoveField(
model_name='rearport',
name='_link_peer_type',
),
]

View File

@@ -1,10 +1,12 @@
import itertools
from collections import defaultdict
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import Sum
from django.dispatch import Signal
from django.urls import reverse
from dcim.choices import *
@@ -13,17 +15,21 @@ from dcim.fields import PathField
from dcim.utils import decompile_path_node, object_to_path_node, path_node_to_object
from netbox.models import NetBoxModel
from utilities.fields import ColorField
from utilities.querysets import RestrictedQuerySet
from utilities.utils import to_meters
from .devices import Device
from wireless.models import WirelessLink
from .device_components import FrontPort, RearPort
__all__ = (
'Cable',
'CablePath',
'CableTermination',
)
trace_paths = Signal()
#
# Cables
#
@@ -32,28 +38,6 @@ class Cable(NetBoxModel):
"""
A physical connection between two endpoints.
"""
termination_a_type = models.ForeignKey(
to=ContentType,
limit_choices_to=CABLE_TERMINATION_MODELS,
on_delete=models.PROTECT,
related_name='+'
)
termination_a_id = models.PositiveBigIntegerField()
termination_a = GenericForeignKey(
ct_field='termination_a_type',
fk_field='termination_a_id'
)
termination_b_type = models.ForeignKey(
to=ContentType,
limit_choices_to=CABLE_TERMINATION_MODELS,
on_delete=models.PROTECT,
related_name='+'
)
termination_b_id = models.PositiveBigIntegerField()
termination_b = GenericForeignKey(
ct_field='termination_b_type',
fk_field='termination_b_id'
)
type = models.CharField(
max_length=50,
choices=CableTypeChoices,
@@ -96,31 +80,11 @@ class Cable(NetBoxModel):
blank=True,
null=True
)
# Cache the associated device (where applicable) for the A and B terminations. This enables filtering of Cables by
# their associated Devices.
_termination_a_device = models.ForeignKey(
to=Device,
on_delete=models.CASCADE,
related_name='+',
blank=True,
null=True
)
_termination_b_device = models.ForeignKey(
to=Device,
on_delete=models.CASCADE,
related_name='+',
blank=True,
null=True
)
class Meta:
ordering = ['pk']
unique_together = (
('termination_a_type', 'termination_a_id'),
('termination_b_type', 'termination_b_id'),
)
ordering = ('pk',)
def __init__(self, *args, **kwargs):
def __init__(self, *args, a_terminations=None, b_terminations=None, **kwargs):
super().__init__(*args, **kwargs)
# A copy of the PK to be used by __str__ in case the object is deleted
@@ -129,19 +93,13 @@ class Cable(NetBoxModel):
# Cache the original status so we can check later if it's been changed
self._orig_status = self.status
@classmethod
def from_db(cls, db, field_names, values):
"""
Cache the original A and B terminations of existing Cable instances for later reference inside clean().
"""
instance = super().from_db(db, field_names, values)
self._terminations_modified = False
instance._orig_termination_a_type_id = instance.termination_a_type_id
instance._orig_termination_a_id = instance.termination_a_id
instance._orig_termination_b_type_id = instance.termination_b_type_id
instance._orig_termination_b_id = instance.termination_b_id
return instance
# Assign or retrieve A/B terminations
if a_terminations:
self.a_terminations = a_terminations
if b_terminations:
self.b_terminations = b_terminations
def __str__(self):
pk = self.pk or self._pk
@@ -150,124 +108,68 @@ class Cable(NetBoxModel):
def get_absolute_url(self):
return reverse('dcim:cable', args=[self.pk])
@property
def a_terminations(self):
if hasattr(self, '_a_terminations'):
return self._a_terminations
# Query self.terminations.all() to leverage cached results
return [
ct.termination for ct in self.terminations.all() if ct.cable_end == CableEndChoices.SIDE_A
]
@a_terminations.setter
def a_terminations(self, value):
self._terminations_modified = True
self._a_terminations = value
@property
def b_terminations(self):
if hasattr(self, '_b_terminations'):
return self._b_terminations
# Query self.terminations.all() to leverage cached results
return [
ct.termination for ct in self.terminations.all() if ct.cable_end == CableEndChoices.SIDE_B
]
@b_terminations.setter
def b_terminations(self, value):
self._terminations_modified = True
self._b_terminations = value
def clean(self):
from circuits.models import CircuitTermination
super().clean()
# Validate that termination A exists
if not hasattr(self, 'termination_a_type'):
raise ValidationError('Termination A type has not been specified')
try:
self.termination_a_type.model_class().objects.get(pk=self.termination_a_id)
except ObjectDoesNotExist:
raise ValidationError({
'termination_a': 'Invalid ID for type {}'.format(self.termination_a_type)
})
# Validate that termination B exists
if not hasattr(self, 'termination_b_type'):
raise ValidationError('Termination B type has not been specified')
try:
self.termination_b_type.model_class().objects.get(pk=self.termination_b_id)
except ObjectDoesNotExist:
raise ValidationError({
'termination_b': 'Invalid ID for type {}'.format(self.termination_b_type)
})
# If editing an existing Cable instance, check that neither termination has been modified.
if self.pk:
err_msg = 'Cable termination points may not be modified. Delete and recreate the cable instead.'
if (
self.termination_a_type_id != self._orig_termination_a_type_id or
self.termination_a_id != self._orig_termination_a_id
):
raise ValidationError({
'termination_a': err_msg
})
if (
self.termination_b_type_id != self._orig_termination_b_type_id or
self.termination_b_id != self._orig_termination_b_id
):
raise ValidationError({
'termination_b': err_msg
})
type_a = self.termination_a_type.model
type_b = self.termination_b_type.model
# Validate interface types
if type_a == 'interface' and self.termination_a.type in NONCONNECTABLE_IFACE_TYPES:
raise ValidationError({
'termination_a_id': 'Cables cannot be terminated to {} interfaces'.format(
self.termination_a.get_type_display()
)
})
if type_b == 'interface' and self.termination_b.type in NONCONNECTABLE_IFACE_TYPES:
raise ValidationError({
'termination_b_id': 'Cables cannot be terminated to {} interfaces'.format(
self.termination_b.get_type_display()
)
})
# Check that termination types are compatible
if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a):
raise ValidationError(
f"Incompatible termination types: {self.termination_a_type} and {self.termination_b_type}"
)
# Check that two connected RearPorts have the same number of positions (if both are >1)
if isinstance(self.termination_a, RearPort) and isinstance(self.termination_b, RearPort):
if self.termination_a.positions > 1 and self.termination_b.positions > 1:
if self.termination_a.positions != self.termination_b.positions:
raise ValidationError(
f"{self.termination_a} has {self.termination_a.positions} position(s) but "
f"{self.termination_b} has {self.termination_b.positions}. "
f"Both terminations must have the same number of positions (if greater than one)."
)
# A termination point cannot be connected to itself
if self.termination_a == self.termination_b:
raise ValidationError(f"Cannot connect {self.termination_a_type} to itself")
# A front port cannot be connected to its corresponding rear port
if (
type_a in ['frontport', 'rearport'] and
type_b in ['frontport', 'rearport'] and
(
getattr(self.termination_a, 'rear_port', None) == self.termination_b or
getattr(self.termination_b, 'rear_port', None) == self.termination_a
)
):
raise ValidationError("A front port cannot be connected to it corresponding rear port")
# A CircuitTermination attached to a ProviderNetwork cannot have a Cable
if isinstance(self.termination_a, CircuitTermination) and self.termination_a.provider_network is not None:
raise ValidationError({
'termination_a_id': "Circuit terminations attached to a provider network may not be cabled."
})
if isinstance(self.termination_b, CircuitTermination) and self.termination_b.provider_network is not None:
raise ValidationError({
'termination_b_id': "Circuit terminations attached to a provider network may not be cabled."
})
# Check for an existing Cable connected to either termination object
if self.termination_a.cable not in (None, self):
raise ValidationError("{} already has a cable attached (#{})".format(
self.termination_a, self.termination_a.cable_id
))
if self.termination_b.cable not in (None, self):
raise ValidationError("{} already has a cable attached (#{})".format(
self.termination_b, self.termination_b.cable_id
))
# Validate length and length_unit
if self.length is not None and not self.length_unit:
raise ValidationError("Must specify a unit when setting a cable length")
elif self.length is None:
self.length_unit = ''
if self.pk is None and (not self.a_terminations or not self.b_terminations):
raise ValidationError("Must define A and B terminations when creating a new cable.")
if self._terminations_modified:
# Check that all termination objects for either end are of the same type
for terms in (self.a_terminations, self.b_terminations):
if len(terms) > 1 and not all(isinstance(t, type(terms[0])) for t in terms[1:]):
raise ValidationError("Cannot connect different termination types to same end of cable.")
# Check that termination types are compatible
if self.a_terminations and self.b_terminations:
a_type = self.a_terminations[0]._meta.model_name
b_type = self.b_terminations[0]._meta.model_name
if b_type not in COMPATIBLE_TERMINATION_TYPES.get(a_type):
raise ValidationError(f"Incompatible termination types: {a_type} and {b_type}")
# Run clean() on any new CableTerminations
for termination in self.a_terminations:
CableTermination(cable=self, cable_end='A', termination=termination).clean()
for termination in self.b_terminations:
CableTermination(cable=self, cable_end='B', termination=termination).clean()
def save(self, *args, **kwargs):
_created = self.pk is None
# Store the given length (if any) in meters for use in database ordering
if self.length and self.length_unit:
@@ -275,199 +177,447 @@ class Cable(NetBoxModel):
else:
self._abs_length = None
# Store the parent Device for the A and B terminations (if applicable) to enable filtering
if hasattr(self.termination_a, 'device'):
self._termination_a_device = self.termination_a.device
if hasattr(self.termination_b, 'device'):
self._termination_b_device = self.termination_b.device
super().save(*args, **kwargs)
# Update the private pk used in __str__ in case this is a new object (i.e. just got its pk)
self._pk = self.pk
# Retrieve existing A/B terminations for the Cable
a_terminations = {ct.termination: ct for ct in self.terminations.filter(cable_end='A')}
b_terminations = {ct.termination: ct for ct in self.terminations.filter(cable_end='B')}
# Delete stale CableTerminations
if self._terminations_modified:
for termination, ct in a_terminations.items():
if termination.pk and termination not in self.a_terminations:
ct.delete()
for termination, ct in b_terminations.items():
if termination.pk and termination not in self.b_terminations:
ct.delete()
# Save new CableTerminations (if any)
if self._terminations_modified:
for termination in self.a_terminations:
if not termination.pk or termination not in a_terminations:
CableTermination(cable=self, cable_end='A', termination=termination).save()
for termination in self.b_terminations:
if not termination.pk or termination not in b_terminations:
CableTermination(cable=self, cable_end='B', termination=termination).save()
trace_paths.send(Cable, instance=self, created=_created)
def get_status_color(self):
return LinkStatusChoices.colors.get(self.status)
def get_compatible_types(self):
class CableTermination(models.Model):
"""
A mapping between side A or B of a Cable and a terminating object (e.g. an Interface or CircuitTermination).
"""
cable = models.ForeignKey(
to='dcim.Cable',
on_delete=models.CASCADE,
related_name='terminations'
)
cable_end = models.CharField(
max_length=1,
choices=CableEndChoices,
verbose_name='End'
)
termination_type = models.ForeignKey(
to=ContentType,
limit_choices_to=CABLE_TERMINATION_MODELS,
on_delete=models.PROTECT,
related_name='+'
)
termination_id = models.PositiveBigIntegerField()
termination = GenericForeignKey(
ct_field='termination_type',
fk_field='termination_id'
)
# Cached associations to enable efficient filtering
_device = models.ForeignKey(
to='dcim.Device',
on_delete=models.CASCADE,
blank=True,
null=True
)
_rack = models.ForeignKey(
to='dcim.Rack',
on_delete=models.CASCADE,
blank=True,
null=True
)
_location = models.ForeignKey(
to='dcim.Location',
on_delete=models.CASCADE,
blank=True,
null=True
)
_site = models.ForeignKey(
to='dcim.Site',
on_delete=models.CASCADE,
blank=True,
null=True
)
objects = RestrictedQuerySet.as_manager()
class Meta:
ordering = ('cable', 'cable_end', 'pk')
constraints = (
models.UniqueConstraint(
fields=('termination_type', 'termination_id'),
name='dcim_cable_termination_unique_termination'
),
)
def __str__(self):
return f'Cable {self.cable} to {self.termination}'
def clean(self):
super().clean()
# Validate interface type (if applicable)
if self.termination_type.model == 'interface' and self.termination.type in NONCONNECTABLE_IFACE_TYPES:
raise ValidationError({
'termination': f'Cables cannot be terminated to {self.termination.get_type_display()} interfaces'
})
# A CircuitTermination attached to a ProviderNetwork cannot have a Cable
if self.termination_type.model == 'circuittermination' and self.termination.provider_network is not None:
raise ValidationError({
'termination': "Circuit terminations attached to a provider network may not be cabled."
})
def save(self, *args, **kwargs):
# Cache objects associated with the terminating object (for filtering)
self.cache_related_objects()
super().save(*args, **kwargs)
# Set the cable on the terminating object
termination_model = self.termination._meta.model
termination_model.objects.filter(pk=self.termination_id).update(
cable=self.cable,
cable_end=self.cable_end
)
def delete(self, *args, **kwargs):
# Delete the cable association on the terminating object
termination_model = self.termination._meta.model
termination_model.objects.filter(pk=self.termination_id).update(
cable=None,
cable_end=''
)
super().delete(*args, **kwargs)
def cache_related_objects(self):
"""
Return all termination types compatible with termination A.
Cache objects related to the termination (e.g. device, rack, site) directly on the object to
enable efficient filtering.
"""
if self.termination_a is None:
return
return COMPATIBLE_TERMINATION_TYPES[self.termination_a._meta.model_name]
assert self.termination is not None
# Device components
if getattr(self.termination, 'device', None):
self._device = self.termination.device
self._rack = self.termination.device.rack
self._location = self.termination.device.location
self._site = self.termination.device.site
# Power feeds
elif getattr(self.termination, 'rack', None):
self._rack = self.termination.rack
self._location = self.termination.rack.location
self._site = self.termination.rack.site
# Circuit terminations
elif getattr(self.termination, 'site', None):
self._site = self.termination.site
class CablePath(models.Model):
"""
A CablePath instance represents the physical path from an origin to a destination, including all intermediate
elements in the path. Every instance must specify an `origin`, whereas `destination` may be null (for paths which do
not terminate on a PathEndpoint).
A CablePath instance represents the physical path from a set of origin nodes to a set of destination nodes,
including all intermediate elements.
`path` contains a list of nodes within the path, each represented by a tuple of (type, ID). The first element in the
path must be a Cable instance, followed by a pair of pass-through ports. For example, consider the following
`path` contains the ordered set of nodes, arranged in lists of (type, ID) tuples. (Each cable in the path can
terminate to one or more objects.) For example, consider the following
topology:
1 2 3
Interface A --- Front Port A | Rear Port A --- Rear Port B | Front Port B --- Interface B
A B C
Interface 1 --- Front Port 1 | Rear Port 1 --- Rear Port 2 | Front Port 3 --- Interface 2
Front Port 2 Front Port 4
This path would be expressed as:
CablePath(
origin = Interface A
destination = Interface B
path = [Cable 1, Front Port A, Rear Port A, Cable 2, Rear Port B, Front Port B, Cable 3]
path = [
[Interface 1],
[Cable A],
[Front Port 1, Front Port 2],
[Rear Port 1],
[Cable B],
[Rear Port 2],
[Front Port 3, Front Port 4],
[Cable C],
[Interface 2],
]
)
`is_active` is set to True only if 1) `destination` is not null, and 2) every Cable within the path has a status of
"connected".
`is_active` is set to True only if every Cable within the path has a status of "connected". `is_complete` is True
if the instance represents a complete end-to-end path from origin(s) to destination(s). `is_split` is True if the
path diverges across multiple cables.
`_nodes` retains a flattened list of all nodes within the path to enable simple filtering.
"""
origin_type = models.ForeignKey(
to=ContentType,
on_delete=models.CASCADE,
related_name='+'
path = models.JSONField(
default=list
)
origin_id = models.PositiveBigIntegerField()
origin = GenericForeignKey(
ct_field='origin_type',
fk_field='origin_id'
)
destination_type = models.ForeignKey(
to=ContentType,
on_delete=models.CASCADE,
related_name='+',
blank=True,
null=True
)
destination_id = models.PositiveBigIntegerField(
blank=True,
null=True
)
destination = GenericForeignKey(
ct_field='destination_type',
fk_field='destination_id'
)
path = PathField()
is_active = models.BooleanField(
default=False
)
is_complete = models.BooleanField(
default=False
)
is_split = models.BooleanField(
default=False
)
class Meta:
unique_together = ('origin_type', 'origin_id')
_nodes = PathField()
def __str__(self):
status = ' (active)' if self.is_active else ' (split)' if self.is_split else ''
return f"Path #{self.pk}: {self.origin} to {self.destination} via {len(self.path)} nodes{status}"
return f"Path #{self.pk}: {len(self.path)} hops"
def save(self, *args, **kwargs):
# Save the flattened nodes list
self._nodes = list(itertools.chain(*self.path))
super().save(*args, **kwargs)
# Record a direct reference to this CablePath on its originating object
model = self.origin._meta.model
model.objects.filter(pk=self.origin.pk).update(_path=self.pk)
# Record a direct reference to this CablePath on its originating object(s)
origin_model = self.origin_type.model_class()
origin_ids = [decompile_path_node(node)[1] for node in self.path[0]]
origin_model.objects.filter(pk__in=origin_ids).update(_path=self.pk)
@property
def origin_type(self):
if self.path:
ct_id, _ = decompile_path_node(self.path[0][0])
return ContentType.objects.get_for_id(ct_id)
@property
def destination_type(self):
if self.is_complete:
ct_id, _ = decompile_path_node(self.path[-1][0])
return ContentType.objects.get_for_id(ct_id)
@property
def path_objects(self):
"""
Cache and return the complete path as lists of objects, derived from their annotation within the path.
"""
if not hasattr(self, '_path_objects'):
self._path_objects = self._get_path()
return self._path_objects
@property
def origins(self):
"""
Return the list of originating objects.
"""
return self.path_objects[0]
@property
def destinations(self):
"""
Return the list of destination objects, if the path is complete.
"""
if not self.is_complete:
return []
return self.path_objects[-1]
@property
def segment_count(self):
total_length = 1 + len(self.path) + (1 if self.destination else 0)
return int(total_length / 3)
return int(len(self.path) / 3)
@classmethod
def from_origin(cls, origin):
def from_origin(cls, terminations):
"""
Create a new CablePath instance as traced from the given path origin.
Create a new CablePath instance as traced from the given termination objects. These can be any object to which a
Cable or WirelessLink connects (interfaces, console ports, circuit termination, etc.). All terminations must be
of the same type and must belong to the same parent object.
"""
from circuits.models import CircuitTermination
if origin is None or origin.link is None:
if not terminations:
return None
destination = None
# Ensure all originating terminations are attached to the same link
if len(terminations) > 1:
assert all(t.link == terminations[0].link for t in terminations[1:])
path = []
position_stack = []
is_complete = False
is_active = True
is_split = False
node = origin
while node.link is not None:
if hasattr(node.link, 'status') and node.link.status != LinkStatusChoices.STATUS_CONNECTED:
while terminations:
# Terminations must all be of the same type
assert all(isinstance(t, type(terminations[0])) for t in terminations[1:])
# Check for a split path (e.g. rear port fanning out to multiple front ports with
# different cables attached)
if len(set(t.link for t in terminations)) > 1:
is_split = True
break
# Step 1: Record the near-end termination object(s)
path.append([
object_to_path_node(t) for t in terminations
])
# Step 2: Determine the attached link (Cable or WirelessLink), if any
link = terminations[0].link
if link is None and len(path) == 1:
# If this is the start of the path and no link exists, return None
return None
elif link is None:
# Otherwise, halt the trace if no link exists
break
assert type(link) in (Cable, WirelessLink)
# Step 3: Record the link and update path status if not "connected"
path.append([object_to_path_node(link)])
if hasattr(link, 'status') and link.status != LinkStatusChoices.STATUS_CONNECTED:
is_active = False
# Follow the link to its far-end termination
path.append(object_to_path_node(node.link))
peer_termination = node.get_link_peer()
# Step 4: Determine the far-end terminations
if isinstance(link, Cable):
termination_type = ContentType.objects.get_for_model(terminations[0])
local_cable_terminations = CableTermination.objects.filter(
termination_type=termination_type,
termination_id__in=[t.pk for t in terminations]
)
# Terminations must all belong to same end of Cable
local_cable_end = local_cable_terminations[0].cable_end
assert all(ct.cable_end == local_cable_end for ct in local_cable_terminations[1:])
remote_cable_terminations = CableTermination.objects.filter(
cable=link,
cable_end='A' if local_cable_end == 'B' else 'B'
)
remote_terminations = [ct.termination for ct in remote_cable_terminations]
else:
# WirelessLink
remote_terminations = [link.interface_b] if link.interface_a is terminations[0] else [link.interface_a]
# Follow a FrontPort to its corresponding RearPort
if isinstance(peer_termination, FrontPort):
path.append(object_to_path_node(peer_termination))
node = peer_termination.rear_port
if node.positions > 1:
position_stack.append(peer_termination.rear_port_position)
path.append(object_to_path_node(node))
# Step 5: Record the far-end termination object(s)
path.append([
object_to_path_node(t) for t in remote_terminations
])
# Follow a RearPort to its corresponding FrontPort (if any)
elif isinstance(peer_termination, RearPort):
path.append(object_to_path_node(peer_termination))
# Step 6: Determine the "next hop" terminations, if applicable
if not remote_terminations:
break
# Determine the peer FrontPort's position
if peer_termination.positions == 1:
position = 1
if isinstance(remote_terminations[0], FrontPort):
# Follow FrontPorts to their corresponding RearPorts
rear_ports = RearPort.objects.filter(
pk__in=[t.rear_port_id for t in remote_terminations]
)
if len(rear_ports) > 1:
assert all(rp.positions == 1 for rp in rear_ports)
elif rear_ports[0].positions > 1:
position_stack.append([fp.rear_port_position for fp in remote_terminations])
terminations = rear_ports
elif isinstance(remote_terminations[0], RearPort):
if len(remote_terminations) > 1 or remote_terminations[0].positions == 1:
front_ports = FrontPort.objects.filter(
rear_port_id__in=[rp.pk for rp in remote_terminations],
rear_port_position=1
)
elif position_stack:
position = position_stack.pop()
front_ports = FrontPort.objects.filter(
rear_port_id=remote_terminations[0].pk,
rear_port_position__in=position_stack.pop()
)
else:
# No position indicated: path has split, so we stop at the RearPort
# No position indicated: path has split, so we stop at the RearPorts
is_split = True
break
try:
node = FrontPort.objects.get(rear_port=peer_termination, rear_port_position=position)
path.append(object_to_path_node(node))
except ObjectDoesNotExist:
# No corresponding FrontPort found for the RearPort
terminations = front_ports
elif isinstance(remote_terminations[0], CircuitTermination):
# Follow a CircuitTermination to its corresponding CircuitTermination (A to Z or vice versa)
term_side = remote_terminations[0].term_side
assert all(ct.term_side == term_side for ct in remote_terminations[1:])
circuit_termination = CircuitTermination.objects.filter(
circuit=remote_terminations[0].circuit,
term_side='Z' if term_side == 'A' else 'A'
).first()
if circuit_termination is None:
break
elif circuit_termination.provider_network:
# Circuit terminates to a ProviderNetwork
path.extend([
[object_to_path_node(circuit_termination)],
[object_to_path_node(circuit_termination.provider_network)],
])
break
elif circuit_termination.site and not circuit_termination.cable:
# Circuit terminates to a Site
path.extend([
[object_to_path_node(circuit_termination)],
[object_to_path_node(circuit_termination.site)],
])
break
# Follow a CircuitTermination to its corresponding CircuitTermination (A to Z or vice versa)
elif isinstance(peer_termination, CircuitTermination):
path.append(object_to_path_node(peer_termination))
# Get peer CircuitTermination
node = peer_termination.get_peer_termination()
if node:
path.append(object_to_path_node(node))
if node.provider_network:
destination = node.provider_network
break
elif node.site and not node.cable:
destination = node.site
break
else:
# No peer CircuitTermination exists; halt the trace
break
terminations = [circuit_termination]
# Anything else marks the end of the path
else:
destination = peer_termination
is_complete = True
break
if destination is None:
is_active = False
return cls(
origin=origin,
destination=destination,
path=path,
is_complete=is_complete,
is_active=is_active,
is_split=is_split
)
def get_path(self):
def retrace(self):
"""
Retrace the path from the currently-defined originating termination(s)
"""
_new = self.from_origin(self.origins)
if _new:
self.path = _new.path
self.is_complete = _new.is_complete
self.is_active = _new.is_active
self.is_split = _new.is_split
self.save()
else:
self.delete()
def _get_path(self):
"""
Return the path as a list of prefetched objects.
"""
# Compile a list of IDs to prefetch for each type of model in the path
to_prefetch = defaultdict(list)
for node in self.path:
for node in self._nodes:
ct_id, object_id = decompile_path_node(node)
to_prefetch[ct_id].append(object_id)
@@ -484,19 +634,19 @@ class CablePath(models.Model):
# Replicate the path using the prefetched objects.
path = []
for node in self.path:
ct_id, object_id = decompile_path_node(node)
path.append(prefetched[ct_id][object_id])
for step in self.path:
nodes = []
for node in step:
ct_id, object_id = decompile_path_node(node)
try:
nodes.append(prefetched[ct_id][object_id])
except KeyError:
# Ignore stale (deleted) object IDs
pass
path.append(nodes)
return path
@property
def last_node(self):
"""
Return either the destination or the last node within the path.
"""
return self.destination or path_node_to_object(self.path[-1])
def get_cable_ids(self):
"""
Return all Cable IDs within the path.
@@ -504,7 +654,7 @@ class CablePath(models.Model):
cable_ct = ContentType.objects.get_for_model(Cable).pk
cable_ids = []
for node in self.path:
for node in self._nodes:
ct, id = decompile_path_node(node)
if ct == cable_ct:
cable_ids.append(id)
@@ -527,6 +677,6 @@ class CablePath(models.Model):
"""
Return all available next segments in a split cable path.
"""
rearport = path_node_to_object(self.path[-1])
rearport = path_node_to_object(self._nodes[-1])
return FrontPort.objects.filter(rear_port=rearport)

View File

@@ -1,6 +1,6 @@
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from mptt.models import MPTTModel, TreeForeignKey
@@ -39,7 +39,10 @@ class ComponentTemplateModel(WebhooksMixin, ChangeLoggedModel):
related_name='%(class)ss'
)
name = models.CharField(
max_length=64
max_length=64,
help_text="""
{module} is accepted as a substitution for the module bay position when attached to a module type.
"""
)
_name = NaturalOrderingField(
target_field='name',
@@ -157,6 +160,14 @@ class ConsolePortTemplate(ModularComponentTemplateModel):
**kwargs
)
def to_yaml(self):
return {
'name': self.name,
'type': self.type,
'label': self.label,
'description': self.description,
}
class ConsoleServerPortTemplate(ModularComponentTemplateModel):
"""
@@ -185,6 +196,14 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel):
**kwargs
)
def to_yaml(self):
return {
'name': self.name,
'type': self.type,
'label': self.label,
'description': self.description,
}
class PowerPortTemplate(ModularComponentTemplateModel):
"""
@@ -236,6 +255,16 @@ class PowerPortTemplate(ModularComponentTemplateModel):
'allocated_draw': f"Allocated draw cannot exceed the maximum draw ({self.maximum_draw}W)."
})
def to_yaml(self):
return {
'name': self.name,
'type': self.type,
'maximum_draw': self.maximum_draw,
'allocated_draw': self.allocated_draw,
'label': self.label,
'description': self.description,
}
class PowerOutletTemplate(ModularComponentTemplateModel):
"""
@@ -298,6 +327,16 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
**kwargs
)
def to_yaml(self):
return {
'name': self.name,
'type': self.type,
'power_port': self.power_port.name if self.power_port else None,
'feed_leg': self.feed_leg,
'label': self.label,
'description': self.description,
}
class InterfaceTemplate(ModularComponentTemplateModel):
"""
@@ -318,6 +357,18 @@ class InterfaceTemplate(ModularComponentTemplateModel):
default=False,
verbose_name='Management only'
)
poe_mode = models.CharField(
max_length=50,
choices=InterfacePoEModeChoices,
blank=True,
verbose_name='PoE mode'
)
poe_type = models.CharField(
max_length=50,
choices=InterfacePoETypeChoices,
blank=True,
verbose_name='PoE type'
)
component_model = Interface
@@ -334,9 +385,22 @@ class InterfaceTemplate(ModularComponentTemplateModel):
label=self.resolve_label(kwargs.get('module')),
type=self.type,
mgmt_only=self.mgmt_only,
poe_mode=self.poe_mode,
poe_type=self.poe_type,
**kwargs
)
def to_yaml(self):
return {
'name': self.name,
'type': self.type,
'mgmt_only': self.mgmt_only,
'label': self.label,
'description': self.description,
'poe_mode': self.poe_mode,
'poe_type': self.poe_type,
}
class FrontPortTemplate(ModularComponentTemplateModel):
"""
@@ -410,6 +474,16 @@ class FrontPortTemplate(ModularComponentTemplateModel):
**kwargs
)
def to_yaml(self):
return {
'name': self.name,
'type': self.type,
'rear_port': self.rear_port.name,
'rear_port_position': self.rear_port_position,
'label': self.label,
'description': self.description,
}
class RearPortTemplate(ModularComponentTemplateModel):
"""
@@ -449,6 +523,15 @@ class RearPortTemplate(ModularComponentTemplateModel):
**kwargs
)
def to_yaml(self):
return {
'name': self.name,
'type': self.type,
'positions': self.positions,
'label': self.label,
'description': self.description,
}
class ModuleBayTemplate(ComponentTemplateModel):
"""
@@ -474,6 +557,14 @@ class ModuleBayTemplate(ComponentTemplateModel):
position=self.position
)
def to_yaml(self):
return {
'name': self.name,
'label': self.label,
'position': self.position,
'description': self.description,
}
class DeviceBayTemplate(ComponentTemplateModel):
"""
@@ -498,6 +589,13 @@ class DeviceBayTemplate(ComponentTemplateModel):
f"Subdevice role of device type ({self.device_type}) must be set to \"parent\" to allow device bays."
)
def to_yaml(self):
return {
'name': self.name,
'label': self.label,
'description': self.description,
}
class InventoryItemTemplate(MPTTModel, ComponentTemplateModel):
"""

View File

@@ -1,6 +1,8 @@
from functools import cached_property
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import Sum
@@ -10,7 +12,6 @@ from mptt.models import MPTTModel, TreeForeignKey
from dcim.choices import *
from dcim.constants import *
from dcim.fields import MACAddressField, WWNField
from dcim.svg import CableTraceSVG
from netbox.models import OrganizationalModel, NetBoxModel
from utilities.choices import ColorChoices
from utilities.fields import ColorField, NaturalOrderingField
@@ -23,7 +24,7 @@ from wireless.utils import get_channel_attr
__all__ = (
'BaseInterface',
'LinkTermination',
'CabledObjectModel',
'ConsolePort',
'ConsoleServerPort',
'DeviceBay',
@@ -102,14 +103,10 @@ class ModularComponentModel(ComponentModel):
abstract = True
class LinkTermination(models.Model):
class CabledObjectModel(models.Model):
"""
An abstract model inherited by all models to which a Cable, WirelessLink, or other such link can terminate. Examples
include most device components, CircuitTerminations, and PowerFeeds. The `cable` and `wireless_link` fields
reference the attached Cable or WirelessLink instance, respectively.
`_link_peer` is a GenericForeignKey used to cache the far-end LinkTermination on the local instance; this is a
shortcut to referencing `instance.link.termination_b`, for example.
An abstract model inherited by all models to which a Cable can terminate. Provides the `cable` and `cable_end`
fields for caching cable associations, as well as `mark_connected` to designate "fake" connections.
"""
cable = models.ForeignKey(
to='dcim.Cable',
@@ -118,36 +115,21 @@ class LinkTermination(models.Model):
blank=True,
null=True
)
_link_peer_type = models.ForeignKey(
to=ContentType,
on_delete=models.SET_NULL,
related_name='+',
cable_end = models.CharField(
max_length=1,
blank=True,
null=True
)
_link_peer_id = models.PositiveBigIntegerField(
blank=True,
null=True
)
_link_peer = GenericForeignKey(
ct_field='_link_peer_type',
fk_field='_link_peer_id'
choices=CableEndChoices
)
mark_connected = models.BooleanField(
default=False,
help_text="Treat as if a cable is connected"
)
# Generic relations to Cable. These ensure that an attached Cable is deleted if the terminated object is deleted.
_cabled_as_a = GenericRelation(
to='dcim.Cable',
content_type_field='termination_a_type',
object_id_field='termination_a_id'
)
_cabled_as_b = GenericRelation(
to='dcim.Cable',
content_type_field='termination_b_type',
object_id_field='termination_b_id'
cable_terminations = GenericRelation(
to='dcim.CableTermination',
content_type_field='termination_type',
object_id_field='termination_id',
related_query_name='%(class)s',
)
class Meta:
@@ -156,22 +138,19 @@ class LinkTermination(models.Model):
def clean(self):
super().clean()
if self.mark_connected and self.cable_id:
if self.cable and not self.cable_end:
raise ValidationError({
"cable_end": "Must specify cable end (A or B) when attaching a cable."
})
if self.cable_end and not self.cable:
raise ValidationError({
"cable_end": "Cable end must not be set without a cable."
})
if self.mark_connected and self.cable:
raise ValidationError({
"mark_connected": "Cannot mark as connected with a cable attached."
})
def get_link_peer(self):
return self._link_peer
@property
def _occupied(self):
return bool(self.mark_connected or self.cable_id)
@property
def parent_object(self):
raise NotImplementedError("CableTermination models must implement parent_object()")
@property
def link(self):
"""
@@ -179,10 +158,31 @@ class LinkTermination(models.Model):
"""
return self.cable
@cached_property
def link_peers(self):
if self.cable:
peers = self.cable.terminations.exclude(cable_end=self.cable_end).prefetch_related('termination')
return [peer.termination for peer in peers]
return []
@property
def _occupied(self):
return bool(self.mark_connected or self.cable_id)
@property
def parent_object(self):
raise NotImplementedError(f"{self.__class__.__name__} models must declare a parent_object property")
@property
def opposite_cable_end(self):
if not self.cable_end:
return None
return CableEndChoices.SIDE_A if self.cable_end == CableEndChoices.SIDE_B else CableEndChoices.SIDE_B
class PathEndpoint(models.Model):
"""
An abstract model inherited by any CableTermination subclass which represents the end of a CablePath; specifically,
An abstract model inherited by any CabledObjectModel subclass which represents the end of a CablePath; specifically,
these include ConsolePort, ConsoleServerPort, PowerPort, PowerOutlet, Interface, and PowerFeed.
`_path` references the CablePath originating from this instance, if any. It is set or cleared by the receivers in
@@ -205,50 +205,48 @@ class PathEndpoint(models.Model):
origin = self
path = []
# Construct the complete path
# Construct the complete path (including e.g. bridged interfaces)
while origin is not None:
if origin._path is None:
break
path.extend([origin, *origin._path.get_path()])
while (len(path) + 1) % 3:
# Pad to ensure we have complete three-tuples (e.g. for paths that end at a non-connected FrontPort)
path.append(None)
path.append(origin._path.destination)
path.extend(origin._path.path_objects)
# Check for bridge interface to continue the trace
origin = getattr(origin._path.destination, 'bridge', None)
# If the path ends at a non-connected pass-through port, pad out the link and far-end terminations
if len(path) % 3 == 1:
path.extend(([], []))
# If the path ends at a site or provider network, inject a null "link" to render an attachment
elif len(path) % 3 == 2:
path.insert(-1, [])
# Return the path as a list of three-tuples (A termination, cable, B termination)
# Check for a bridged relationship to continue the trace
destinations = origin._path.destinations
if len(destinations) == 1:
origin = getattr(destinations[0], 'bridge', None)
else:
origin = None
# Return the path as a list of three-tuples (A termination(s), cable(s), B termination(s))
return list(zip(*[iter(path)] * 3))
def get_trace_svg(self, base_url=None, width=None):
if width is not None:
trace = CableTraceSVG(self, base_url=base_url, width=width)
else:
trace = CableTraceSVG(self, base_url=base_url)
return trace.render()
@property
def path(self):
return self._path
@property
def connected_endpoint(self):
@cached_property
def connected_endpoints(self):
"""
Caching accessor for the attached CablePath's destination (if any)
"""
if not hasattr(self, '_connected_endpoint'):
self._connected_endpoint = self._path.destination if self._path else None
return self._connected_endpoint
return self._path.destinations if self._path else []
#
# Console components
#
class ConsolePort(ModularComponentModel, LinkTermination, PathEndpoint):
class ConsolePort(ModularComponentModel, CabledObjectModel, PathEndpoint):
"""
A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
"""
@@ -275,7 +273,7 @@ class ConsolePort(ModularComponentModel, LinkTermination, PathEndpoint):
return reverse('dcim:consoleport', kwargs={'pk': self.pk})
class ConsoleServerPort(ModularComponentModel, LinkTermination, PathEndpoint):
class ConsoleServerPort(ModularComponentModel, CabledObjectModel, PathEndpoint):
"""
A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
"""
@@ -306,7 +304,7 @@ class ConsoleServerPort(ModularComponentModel, LinkTermination, PathEndpoint):
# Power components
#
class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint):
class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint):
"""
A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
"""
@@ -347,36 +345,57 @@ class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint):
'allocated_draw': f"Allocated draw cannot exceed the maximum draw ({self.maximum_draw}W)."
})
def get_downstream_powerports(self, leg=None):
"""
Return a queryset of all PowerPorts connected via cable to a child PowerOutlet. For example, in the topology
below, PP1.get_downstream_powerports() would return PP2-4.
---- PO1 <---> PP2
/
PP1 ------- PO2 <---> PP3
\
---- PO3 <---> PP4
"""
poweroutlets = self.poweroutlets.filter(cable__isnull=False)
if leg:
poweroutlets = poweroutlets.filter(feed_leg=leg)
if not poweroutlets:
return PowerPort.objects.none()
q = Q()
for poweroutlet in poweroutlets:
q |= Q(
cable=poweroutlet.cable,
cable_end=poweroutlet.opposite_cable_end
)
return PowerPort.objects.filter(q)
def get_power_draw(self):
"""
Return the allocated and maximum power draw (in VA) and child PowerOutlet count for this PowerPort.
"""
from dcim.models import PowerFeed
# Calculate aggregate draw of all child power outlets if no numbers have been defined manually
if self.allocated_draw is None and self.maximum_draw is None:
poweroutlet_ct = ContentType.objects.get_for_model(PowerOutlet)
outlet_ids = PowerOutlet.objects.filter(power_port=self).values_list('pk', flat=True)
utilization = PowerPort.objects.filter(
_link_peer_type=poweroutlet_ct,
_link_peer_id__in=outlet_ids
).aggregate(
utilization = self.get_downstream_powerports().aggregate(
maximum_draw_total=Sum('maximum_draw'),
allocated_draw_total=Sum('allocated_draw'),
)
ret = {
'allocated': utilization['allocated_draw_total'] or 0,
'maximum': utilization['maximum_draw_total'] or 0,
'outlet_count': len(outlet_ids),
'outlet_count': self.poweroutlets.count(),
'legs': [],
}
# Calculate per-leg aggregates for three-phase feeds
if getattr(self._link_peer, 'phase', None) == PowerFeedPhaseChoices.PHASE_3PHASE:
# Calculate per-leg aggregates for three-phase power feeds
if len(self.link_peers) == 1 and isinstance(self.link_peers[0], PowerFeed) and \
self.link_peers[0].phase == PowerFeedPhaseChoices.PHASE_3PHASE:
for leg, leg_name in PowerOutletFeedLegChoices:
outlet_ids = PowerOutlet.objects.filter(power_port=self, feed_leg=leg).values_list('pk', flat=True)
utilization = PowerPort.objects.filter(
_link_peer_type=poweroutlet_ct,
_link_peer_id__in=outlet_ids
).aggregate(
utilization = self.get_downstream_powerports(leg=leg).aggregate(
maximum_draw_total=Sum('maximum_draw'),
allocated_draw_total=Sum('allocated_draw'),
)
@@ -384,7 +403,7 @@ class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint):
'name': leg_name,
'allocated': utilization['allocated_draw_total'] or 0,
'maximum': utilization['maximum_draw_total'] or 0,
'outlet_count': len(outlet_ids),
'outlet_count': self.poweroutlets.filter(feed_leg=leg).count(),
})
return ret
@@ -393,12 +412,12 @@ class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint):
return {
'allocated': self.allocated_draw or 0,
'maximum': self.maximum_draw or 0,
'outlet_count': PowerOutlet.objects.filter(power_port=self).count(),
'outlet_count': self.poweroutlets.count(),
'legs': [],
}
class PowerOutlet(ModularComponentModel, LinkTermination, PathEndpoint):
class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint):
"""
A physical power outlet (output) within a Device which provides power to a PowerPort.
"""
@@ -436,9 +455,7 @@ class PowerOutlet(ModularComponentModel, LinkTermination, PathEndpoint):
# Validate power port assignment
if self.power_port and self.power_port.device != self.device:
raise ValidationError(
"Parent power port ({}) must belong to the same device".format(self.power_port)
)
raise ValidationError(f"Parent power port ({self.power_port}) must belong to the same device")
#
@@ -512,7 +529,7 @@ class BaseInterface(models.Model):
return self.fhrp_group_assignments.count()
class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpoint):
class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEndpoint):
"""
A network interface within a Device. A physical Interface can connect to exactly one other Interface.
"""
@@ -589,6 +606,18 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo
validators=(MaxValueValidator(127),),
verbose_name='Transmit power (dBm)'
)
poe_mode = models.CharField(
max_length=50,
choices=InterfacePoEModeChoices,
blank=True,
verbose_name='PoE mode'
)
poe_type = models.CharField(
max_length=50,
choices=InterfacePoETypeChoices,
blank=True,
verbose_name='PoE type'
)
wireless_link = models.ForeignKey(
to='wireless.WirelessLink',
on_delete=models.SET_NULL,
@@ -636,8 +665,14 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo
object_id_field='interface_id',
related_query_name='+'
)
l2vpn_terminations = GenericRelation(
to='ipam.L2VPNTermination',
content_type_field='assigned_object_type',
object_id_field='assigned_object_id',
related_query_name='interface',
)
clone_fields = ['device', 'parent', 'bridge', 'lag', 'type', 'mgmt_only']
clone_fields = ['device', 'parent', 'bridge', 'lag', 'type', 'mgmt_only', 'poe_mode', 'poe_type']
class Meta:
ordering = ('device', CollateAsChar('_name'))
@@ -725,6 +760,24 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo
f"of virtual chassis {self.device.virtual_chassis}."
})
# PoE validation
# Only physical interfaces may have a PoE mode/type assigned
if self.poe_mode and self.is_virtual:
raise ValidationError({
'poe_mode': "Virtual interfaces cannot have a PoE mode."
})
if self.poe_type and self.is_virtual:
raise ValidationError({
'poe_type': "Virtual interfaces cannot have a PoE type."
})
# An interface with a PoE type set must also specify a mode
if self.poe_type and not self.poe_mode:
raise ValidationError({
'poe_type': "Must specify PoE mode when designating a PoE type."
})
# Wireless validation
# RF role & channel may only be set for wireless interfaces
@@ -792,12 +845,28 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo
def link(self):
return self.cable or self.wireless_link
@cached_property
def link_peers(self):
if self.cable:
return super().link_peers
if self.wireless_link:
# Return the opposite side of the attached wireless link
if self.wireless_link.interface_a == self:
return [self.wireless_link.interface_b]
else:
return [self.wireless_link.interface_a]
return []
@property
def l2vpn_termination(self):
return self.l2vpn_terminations.first()
#
# Pass-through ports
#
class FrontPort(ModularComponentModel, LinkTermination):
class FrontPort(ModularComponentModel, CabledObjectModel):
"""
A pass-through port on the front of a Device.
"""
@@ -850,7 +919,7 @@ class FrontPort(ModularComponentModel, LinkTermination):
})
class RearPort(ModularComponentModel, LinkTermination):
class RearPort(ModularComponentModel, CabledObjectModel):
"""
A pass-through port on the rear of a Device.
"""

View File

@@ -1,4 +1,4 @@
from collections import OrderedDict
import decimal
import yaml
from django.contrib.contenttypes.fields import GenericRelation
@@ -99,8 +99,10 @@ class DeviceType(NetBoxModel):
blank=True,
help_text='Discrete part number (optional)'
)
u_height = models.PositiveSmallIntegerField(
default=1,
u_height = models.DecimalField(
max_digits=4,
decimal_places=1,
default=1.0,
verbose_name='Height (U)'
)
is_full_depth = models.BooleanField(
@@ -161,115 +163,54 @@ class DeviceType(NetBoxModel):
return reverse('dcim:devicetype', args=[self.pk])
def to_yaml(self):
data = OrderedDict((
('manufacturer', self.manufacturer.name),
('model', self.model),
('slug', self.slug),
('part_number', self.part_number),
('u_height', self.u_height),
('is_full_depth', self.is_full_depth),
('subdevice_role', self.subdevice_role),
('airflow', self.airflow),
('comments', self.comments),
))
data = {
'manufacturer': self.manufacturer.name,
'model': self.model,
'slug': self.slug,
'part_number': self.part_number,
'u_height': float(self.u_height),
'is_full_depth': self.is_full_depth,
'subdevice_role': self.subdevice_role,
'airflow': self.airflow,
'comments': self.comments,
}
# Component templates
if self.consoleporttemplates.exists():
data['console-ports'] = [
{
'name': c.name,
'type': c.type,
'label': c.label,
'description': c.description,
}
for c in self.consoleporttemplates.all()
c.to_yaml() for c in self.consoleporttemplates.all()
]
if self.consoleserverporttemplates.exists():
data['console-server-ports'] = [
{
'name': c.name,
'type': c.type,
'label': c.label,
'description': c.description,
}
for c in self.consoleserverporttemplates.all()
c.to_yaml() for c in self.consoleserverporttemplates.all()
]
if self.powerporttemplates.exists():
data['power-ports'] = [
{
'name': c.name,
'type': c.type,
'maximum_draw': c.maximum_draw,
'allocated_draw': c.allocated_draw,
'label': c.label,
'description': c.description,
}
for c in self.powerporttemplates.all()
c.to_yaml() for c in self.powerporttemplates.all()
]
if self.poweroutlettemplates.exists():
data['power-outlets'] = [
{
'name': c.name,
'type': c.type,
'power_port': c.power_port.name if c.power_port else None,
'feed_leg': c.feed_leg,
'label': c.label,
'description': c.description,
}
for c in self.poweroutlettemplates.all()
c.to_yaml() for c in self.poweroutlettemplates.all()
]
if self.interfacetemplates.exists():
data['interfaces'] = [
{
'name': c.name,
'type': c.type,
'mgmt_only': c.mgmt_only,
'label': c.label,
'description': c.description,
}
for c in self.interfacetemplates.all()
c.to_yaml() for c in self.interfacetemplates.all()
]
if self.frontporttemplates.exists():
data['front-ports'] = [
{
'name': c.name,
'type': c.type,
'rear_port': c.rear_port.name,
'rear_port_position': c.rear_port_position,
'label': c.label,
'description': c.description,
}
for c in self.frontporttemplates.all()
c.to_yaml() for c in self.frontporttemplates.all()
]
if self.rearporttemplates.exists():
data['rear-ports'] = [
{
'name': c.name,
'type': c.type,
'positions': c.positions,
'label': c.label,
'description': c.description,
}
for c in self.rearporttemplates.all()
c.to_yaml() for c in self.rearporttemplates.all()
]
if self.modulebaytemplates.exists():
data['module-bays'] = [
{
'name': c.name,
'label': c.label,
'position': c.position,
'description': c.description,
}
for c in self.modulebaytemplates.all()
c.to_yaml() for c in self.modulebaytemplates.all()
]
if self.devicebaytemplates.exists():
data['device-bays'] = [
{
'name': c.name,
'label': c.label,
'description': c.description,
}
for c in self.devicebaytemplates.all()
c.to_yaml() for c in self.devicebaytemplates.all()
]
return yaml.dump(dict(data), sort_keys=False)
@@ -277,6 +218,12 @@ class DeviceType(NetBoxModel):
def clean(self):
super().clean()
# U height must be divisible by 0.5
if self.u_height % decimal.Decimal(0.5):
raise ValidationError({
'u_height': "U height must be in increments of 0.5 rack units."
})
# If editing an existing DeviceType to have a larger u_height, first validate that *all* instances of it have
# room to expand within their racks. This validation will impose a very high performance penalty when there are
# many instances to check, but increasing the u_height of a DeviceType should be a very rare occurrence.
@@ -395,91 +342,41 @@ class ModuleType(NetBoxModel):
return reverse('dcim:moduletype', args=[self.pk])
def to_yaml(self):
data = OrderedDict((
('manufacturer', self.manufacturer.name),
('model', self.model),
('part_number', self.part_number),
('comments', self.comments),
))
data = {
'manufacturer': self.manufacturer.name,
'model': self.model,
'part_number': self.part_number,
'comments': self.comments,
}
# Component templates
if self.consoleporttemplates.exists():
data['console-ports'] = [
{
'name': c.name,
'type': c.type,
'label': c.label,
'description': c.description,
}
for c in self.consoleporttemplates.all()
c.to_yaml() for c in self.consoleporttemplates.all()
]
if self.consoleserverporttemplates.exists():
data['console-server-ports'] = [
{
'name': c.name,
'type': c.type,
'label': c.label,
'description': c.description,
}
for c in self.consoleserverporttemplates.all()
c.to_yaml() for c in self.consoleserverporttemplates.all()
]
if self.powerporttemplates.exists():
data['power-ports'] = [
{
'name': c.name,
'type': c.type,
'maximum_draw': c.maximum_draw,
'allocated_draw': c.allocated_draw,
'label': c.label,
'description': c.description,
}
for c in self.powerporttemplates.all()
c.to_yaml() for c in self.powerporttemplates.all()
]
if self.poweroutlettemplates.exists():
data['power-outlets'] = [
{
'name': c.name,
'type': c.type,
'power_port': c.power_port.name if c.power_port else None,
'feed_leg': c.feed_leg,
'label': c.label,
'description': c.description,
}
for c in self.poweroutlettemplates.all()
c.to_yaml() for c in self.poweroutlettemplates.all()
]
if self.interfacetemplates.exists():
data['interfaces'] = [
{
'name': c.name,
'type': c.type,
'mgmt_only': c.mgmt_only,
'label': c.label,
'description': c.description,
}
for c in self.interfacetemplates.all()
c.to_yaml() for c in self.interfacetemplates.all()
]
if self.frontporttemplates.exists():
data['front-ports'] = [
{
'name': c.name,
'type': c.type,
'rear_port': c.rear_port.name,
'rear_port_position': c.rear_port_position,
'label': c.label,
'description': c.description,
}
for c in self.frontporttemplates.all()
c.to_yaml() for c in self.frontporttemplates.all()
]
if self.rearporttemplates.exists():
data['rear-ports'] = [
{
'name': c.name,
'type': c.type,
'positions': c.positions,
'label': c.label,
'description': c.description,
}
for c in self.rearporttemplates.all()
c.to_yaml() for c in self.rearporttemplates.all()
]
return yaml.dump(dict(data), sort_keys=False)
@@ -654,10 +551,12 @@ class Device(NetBoxModel, ConfigContextModel):
blank=True,
null=True
)
position = models.PositiveSmallIntegerField(
position = models.DecimalField(
max_digits=4,
decimal_places=1,
blank=True,
null=True,
validators=[MinValueValidator(1)],
validators=[MinValueValidator(1), MaxValueValidator(99.5)],
verbose_name='Position (U)',
help_text='The lowest-numbered unit occupied by the device'
)
@@ -807,7 +706,11 @@ class Device(NetBoxModel, ConfigContextModel):
'position': "Cannot select a rack position without assigning a rack.",
})
# Validate position/face combination
# Validate rack position and face
if self.position and self.position % decimal.Decimal(0.5):
raise ValidationError({
'position': "Position must be in increments of 0.5 rack units."
})
if self.position and not self.face:
raise ValidationError({
'face': "Must specify rack face when defining rack position.",

View File

@@ -9,7 +9,7 @@ from dcim.constants import *
from netbox.config import ConfigItem
from netbox.models import NetBoxModel
from utilities.validators import ExclusionValidator
from .device_components import LinkTermination, PathEndpoint
from .device_components import CabledObjectModel, PathEndpoint
__all__ = (
'PowerFeed',
@@ -67,7 +67,7 @@ class PowerPanel(NetBoxModel):
)
class PowerFeed(NetBoxModel, PathEndpoint, LinkTermination):
class PowerFeed(NetBoxModel, PathEndpoint, CabledObjectModel):
"""
An electrical circuit delivered from a PowerPanel.
"""

View File

@@ -1,4 +1,4 @@
from collections import OrderedDict
import decimal
from django.contrib.auth.models import User
from django.contrib.contenttypes.fields import GenericRelation
@@ -13,11 +13,10 @@ from django.urls import reverse
from dcim.choices import *
from dcim.constants import *
from dcim.svg import RackElevationSVG
from netbox.config import get_config
from netbox.models import OrganizationalModel, NetBoxModel
from utilities.choices import ColorChoices
from utilities.fields import ColorField, NaturalOrderingField
from utilities.utils import array_to_string
from utilities.utils import array_to_string, drange
from .device_components import PowerOutlet, PowerPort
from .devices import Device
from .power import PowerFeed
@@ -242,10 +241,12 @@ class Rack(NetBoxModel):
@property
def units(self):
"""
Return a list of unit numbers, top to bottom.
"""
if self.desc_units:
return range(1, self.u_height + 1)
else:
return reversed(range(1, self.u_height + 1))
return drange(decimal.Decimal(1.0), self.u_height + 1, 0.5)
return drange(self.u_height + decimal.Decimal(0.5), 0.5, -0.5)
def get_status_color(self):
return RackStatusChoices.colors.get(self.status)
@@ -263,12 +264,12 @@ class Rack(NetBoxModel):
reference to the device. When False, only the bottom most unit for a device is included and that unit
contains a height attribute for the device
"""
elevation = OrderedDict()
elevation = {}
for u in self.units:
u_name = f'U{u}'.split('.')[0] if not u % 1 else f'U{u}'
elevation[u] = {
'id': u,
'name': f'U{u}',
'name': u_name,
'face': face,
'device': None,
'occupied': False
@@ -278,7 +279,7 @@ class Rack(NetBoxModel):
if self.pk:
# Retrieve all devices installed within the rack
queryset = Device.objects.prefetch_related(
devices = Device.objects.prefetch_related(
'device_type',
'device_type__manufacturer',
'device_role'
@@ -299,9 +300,9 @@ class Rack(NetBoxModel):
if user is not None:
permitted_device_ids = self.devices.restrict(user, 'view').values_list('pk', flat=True)
for device in queryset:
for device in devices:
if expand_devices:
for u in range(device.position, device.position + device.device_type.u_height):
for u in drange(device.position, device.position + device.device_type.u_height, 0.5):
if user is None or device.pk in permitted_device_ids:
elevation[u]['device'] = device
elevation[u]['occupied'] = True
@@ -310,8 +311,6 @@ class Rack(NetBoxModel):
elevation[device.position]['device'] = device
elevation[device.position]['occupied'] = True
elevation[device.position]['height'] = device.device_type.u_height
for u in range(device.position + 1, device.position + device.device_type.u_height):
elevation.pop(u, None)
return [u for u in elevation.values()]
@@ -331,12 +330,12 @@ class Rack(NetBoxModel):
devices = devices.exclude(pk__in=exclude)
# Initialize the rack unit skeleton
units = list(range(1, self.u_height + 1))
units = list(self.units)
# Remove units consumed by installed devices
for d in devices:
if rack_face is None or d.face == rack_face or d.device_type.is_full_depth:
for u in range(d.position, d.position + d.device_type.u_height):
for u in drange(d.position, d.position + d.device_type.u_height, 0.5):
try:
units.remove(u)
except ValueError:
@@ -346,7 +345,7 @@ class Rack(NetBoxModel):
# Remove units without enough space above them to accommodate a device of the specified height
available_units = []
for u in units:
if set(range(u, u + u_height)).issubset(units):
if set(drange(u, u + u_height, 0.5)).issubset(units):
available_units.append(u)
return list(reversed(available_units))
@@ -356,9 +355,9 @@ class Rack(NetBoxModel):
Return a dictionary mapping all reserved units within the rack to their reservation.
"""
reserved_units = {}
for r in self.reservations.all():
for u in r.units:
reserved_units[u] = r
for reservation in self.reservations.all():
for u in reservation.units:
reserved_units[u] = reservation
return reserved_units
def get_elevation_svg(
@@ -367,9 +366,11 @@ class Rack(NetBoxModel):
user=None,
unit_width=None,
unit_height=None,
legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT,
legend_width=RACK_ELEVATION_DEFAULT_LEGEND_WIDTH,
margin_width=RACK_ELEVATION_DEFAULT_MARGIN_WIDTH,
include_images=True,
base_url=None
base_url=None,
highlight_params=None
):
"""
Return an SVG of the rack elevation
@@ -381,16 +382,23 @@ class Rack(NetBoxModel):
: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 margin_width: Width of the rigth-hand margin, in pixels
:param include_images: Embed front/rear device images where available
:param base_url: Base URL for links and images. If none, URLs will be relative.
"""
elevation = RackElevationSVG(self, user=user, include_images=include_images, base_url=base_url)
if unit_width is None or unit_height is None:
config = get_config()
unit_width = unit_width or config.RACK_ELEVATION_DEFAULT_UNIT_WIDTH
unit_height = unit_height or config.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT
elevation = RackElevationSVG(
self,
unit_width=unit_width,
unit_height=unit_height,
legend_width=legend_width,
margin_width=margin_width,
user=user,
include_images=include_images,
base_url=base_url,
highlight_params=highlight_params
)
return elevation.render(face, unit_width, unit_height, legend_width)
return elevation.render(face)
def get_0u_devices(self):
return self.devices.filter(position=0)
@@ -401,6 +409,7 @@ class Rack(NetBoxModel):
as utilized.
"""
# Determine unoccupied units
total_units = len(list(self.units))
available_units = self.get_available_units()
# Remove reserved units
@@ -408,8 +417,8 @@ class Rack(NetBoxModel):
if u in available_units:
available_units.remove(u)
occupied_unit_count = self.u_height - len(available_units)
percentage = float(occupied_unit_count) / self.u_height * 100
occupied_unit_count = total_units - len(available_units)
percentage = float(occupied_unit_count) / total_units * 100
return percentage
@@ -422,17 +431,17 @@ class Rack(NetBoxModel):
if not available_power_total:
return 0
pf_powerports = PowerPort.objects.filter(
_link_peer_type=ContentType.objects.get_for_model(PowerFeed),
_link_peer_id__in=powerfeeds.values_list('id', flat=True)
)
poweroutlets = PowerOutlet.objects.filter(power_port_id__in=pf_powerports)
allocated_draw_total = PowerPort.objects.filter(
_link_peer_type=ContentType.objects.get_for_model(PowerOutlet),
_link_peer_id__in=poweroutlets.values_list('id', flat=True)
).aggregate(Sum('allocated_draw'))['allocated_draw__sum'] or 0
powerports = []
for powerfeed in powerfeeds:
powerports.extend([
peer for peer in powerfeed.link_peers if isinstance(peer, PowerPort)
])
return int(allocated_draw_total / available_power_total * 100)
allocated_draw = sum([
powerport.get_power_draw()['allocated'] for powerport in powerports
])
return int(allocated_draw / available_power_total * 100)
class RackReservation(NetBoxModel):

View File

@@ -341,6 +341,11 @@ class Location(NestedGroupModel):
null=True,
db_index=True
)
status = models.CharField(
max_length=50,
choices=LocationStatusChoices,
default=LocationStatusChoices.STATUS_ACTIVE
)
tenant = models.ForeignKey(
to='tenancy.Tenant',
on_delete=models.PROTECT,
@@ -367,7 +372,7 @@ class Location(NestedGroupModel):
to='extras.ImageAttachment'
)
clone_fields = ['site', 'parent', 'tenant', 'description']
clone_fields = ['site', 'parent', 'status', 'tenant', 'description']
class Meta:
ordering = ['site', 'name']
@@ -409,6 +414,9 @@ class Location(NestedGroupModel):
def get_absolute_url(self):
return reverse('dcim:location', args=[self.pk])
def get_status_color(self):
return LocationStatusChoices.colors.get(self.status)
def clean(self):
super().clean()

View File

@@ -1,11 +1,11 @@
import logging
from django.contrib.contenttypes.models import ContentType
from django.db.models.signals import post_save, post_delete, pre_delete
from django.dispatch import receiver
from .choices import LinkStatusChoices
from .models import Cable, CablePath, Device, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis
from .choices import CableEndChoices, LinkStatusChoices
from .models import Cable, CablePath, CableTermination, Device, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis
from .models.cables import trace_paths
from .utils import create_cablepath, rebuild_paths
@@ -68,73 +68,58 @@ def clear_virtualchassis_members(instance, **kwargs):
# Cables
#
@receiver(post_save, sender=Cable)
@receiver(trace_paths, sender=Cable)
def update_connected_endpoints(instance, created, raw=False, **kwargs):
"""
When a Cable is saved, check for and update its two connected endpoints
When a Cable is saved with new terminations, retrace any affected cable paths.
"""
logger = logging.getLogger('netbox.dcim.cable')
if raw:
logger.debug(f"Skipping endpoint updates for imported cable {instance}")
return
# Cache the Cable on its two termination points
if instance.termination_a.cable != instance:
logger.debug(f"Updating termination A for cable {instance}")
instance.termination_a.cable = instance
instance.termination_a._link_peer = instance.termination_b
instance.termination_a.save()
if instance.termination_b.cable != instance:
logger.debug(f"Updating termination B for cable {instance}")
instance.termination_b.cable = instance
instance.termination_b._link_peer = instance.termination_a
instance.termination_b.save()
# Create/update cable paths
if created:
for termination in (instance.termination_a, instance.termination_b):
if isinstance(termination, PathEndpoint):
create_cablepath(termination)
# Update cable paths if new terminations have been set
if instance._terminations_modified:
a_terminations = []
b_terminations = []
for t in instance.terminations.all():
if t.cable_end == CableEndChoices.SIDE_A:
a_terminations.append(t.termination)
else:
rebuild_paths(termination)
b_terminations.append(t.termination)
for nodes in [a_terminations, b_terminations]:
# Examine type of first termination to determine object type (all must be the same)
if not nodes:
continue
if isinstance(nodes[0], PathEndpoint):
create_cablepath(nodes)
else:
rebuild_paths(nodes)
# Update status of CablePaths if Cable status has been changed
elif instance.status != instance._orig_status:
# We currently don't support modifying either termination of an existing Cable. (This
# may change in the future.) However, we do need to capture status changes and update
# any CablePaths accordingly.
if instance.status != LinkStatusChoices.STATUS_CONNECTED:
CablePath.objects.filter(path__contains=instance).update(is_active=False)
CablePath.objects.filter(_nodes__contains=instance).update(is_active=False)
else:
rebuild_paths(instance)
rebuild_paths([instance])
@receiver(post_delete, sender=Cable)
def retrace_cable_paths(instance, **kwargs):
"""
When a Cable is deleted, check for and update its connected endpoints
"""
for cablepath in CablePath.objects.filter(_nodes__contains=instance):
cablepath.retrace()
@receiver(post_delete, sender=CableTermination)
def nullify_connected_endpoints(instance, **kwargs):
"""
When a Cable is deleted, check for and update its two connected endpoints
Disassociate the Cable from the termination object, and retrace any affected CablePaths.
"""
logger = logging.getLogger('netbox.dcim.cable')
model = instance.termination_type.model_class()
model.objects.filter(pk=instance.termination_id).update(cable=None, cable_end='')
# Disassociate the Cable from its termination points
if instance.termination_a is not None:
logger.debug(f"Nullifying termination A for cable {instance}")
model = instance.termination_a._meta.model
model.objects.filter(pk=instance.termination_a.pk).update(_link_peer_type=None, _link_peer_id=None)
if instance.termination_b is not None:
logger.debug(f"Nullifying termination B for cable {instance}")
model = instance.termination_b._meta.model
model.objects.filter(pk=instance.termination_b.pk).update(_link_peer_type=None, _link_peer_id=None)
# Delete and retrace any dependent cable paths
for cablepath in CablePath.objects.filter(path__contains=instance):
cp = CablePath.from_origin(cablepath.origin)
if cp:
CablePath.objects.filter(pk=cablepath.pk).update(
path=cp.path,
destination_type=ContentType.objects.get_for_model(cp.destination) if cp.destination else None,
destination_id=cp.destination.pk if cp.destination else None,
is_active=cp.is_active,
is_split=cp.is_split
)
else:
cablepath.delete()
for cablepath in CablePath.objects.filter(_nodes__contains=instance.cable):
cablepath.retrace()

View File

@@ -1,599 +0,0 @@
import svgwrite
from svgwrite.container import Group, Hyperlink
from svgwrite.shapes import Line, Rect
from svgwrite.text import Text
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
__all__ = (
'CableTraceSVG',
'RackElevationSVG',
)
def get_device_name(device):
if device.virtual_chassis:
return f'{device.virtual_chassis.name}:{device.vc_position}'
elif device.name:
return device.name
else:
return str(device.device_type)
class RackElevationSVG:
"""
Use this class to render a rack elevation as an SVG image.
:param rack: A NetBox Rack instance
:param user: User instance. If specified, only devices viewable by this user will be fully displayed.
:param include_images: If true, the SVG document will embed front/rear device face images, where available
:param base_url: Base URL for links within the SVG document. If none, links will be relative.
"""
def __init__(self, rack, user=None, include_images=True, base_url=None):
self.rack = rack
self.include_images = include_images
if base_url is not None:
self.base_url = base_url.rstrip('/')
else:
self.base_url = ''
# Determine the subset of devices within this rack that are viewable by the user, if any
permitted_devices = self.rack.devices
if user is not None:
permitted_devices = permitted_devices.restrict(user, 'view')
self.permitted_device_ids = permitted_devices.values_list('pk', flat=True)
@staticmethod
def _get_device_description(device):
return '{} ({}) — {} {} ({}U) {} {}'.format(
device.name,
device.device_role,
device.device_type.manufacturer.name,
device.device_type.model,
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('{}/rack_elevation.css'.format(settings.STATIC_ROOT)) 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 = get_device_name(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='{}{}'.format(self.base_url, 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
# Convert any relative URLs to absolute
if url.startswith('/'):
url = '{}{}'.format(self.base_url, url)
image = drawing.image(
href=url,
insert=start,
size=end,
class_='device-image'
)
image.fit(scale='slice')
link.add(image)
link.add(drawing.text(str(name), insert=text, stroke='black',
stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label'))
link.add(drawing.text(str(name), insert=text, fill='white', class_='device-image-label'))
def _draw_device_rear(self, drawing, device, start, end, text):
link = drawing.add(
drawing.a(
href='{}{}'.format(self.base_url, 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, class_="slot blocked"))
link.add(drawing.text(get_device_name(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
# Convert any relative URLs to absolute
if url.startswith('/'):
url = '{}{}'.format(self.base_url, url)
image = drawing.image(
href=url,
insert=start,
size=end,
class_='device-image'
)
image.fit(scale='slice')
link.add(image)
link.add(drawing.text(get_device_name(device), insert=text, stroke='black',
stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label'))
link.add(drawing.text(get_device_name(device), insert=text, fill='white', class_='device-image-label'))
def _draw_empty(self, drawing, rack, start, end, text, id_, face_id, class_, reservation):
link_url = '{}{}?{}'.format(
self.base_url,
reverse('dcim:device_add'),
urlencode({
'site': rack.site.pk,
'location': rack.location.pk if rack.location else '',
'rack': rack.pk,
'face': face_id,
'position': id_
})
)
link = drawing.add(
drawing.a(href=link_url, 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'] and o['device'].device_type.is_full_depth:
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 and device.pk in self.permitted_device_ids:
self._draw_device_front(drawing, device, start_cordinates, end_cordinates, text_cordinates)
elif device and device.device_type.is_full_depth and device.pk in self.permitted_device_ids:
self._draw_device_rear(drawing, device, start_cordinates, end_cordinates, text_cordinates)
elif device:
# Devices which the user does not have permission to view are rendered only as unavailable space
drawing.add(drawing.rect(start_cordinates, end_cordinates, class_='blocked'))
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
OFFSET = 0.5
PADDING = 10
LINE_HEIGHT = 20
class CableTraceSVG:
"""
Generate a graphical representation of a CablePath in SVG format.
:param origin: The originating termination
:param width: Width of the generated image (in pixels)
:param base_url: Base URL for links within the SVG document. If none, links will be relative.
"""
def __init__(self, origin, width=400, base_url=None):
self.origin = origin
self.width = width
self.base_url = base_url.rstrip('/') if base_url is not None else ''
# Establish a cursor to track position on the y axis
# Center edges on pixels to render sharp borders
self.cursor = OFFSET
@property
def center(self):
return self.width / 2
@classmethod
def _get_labels(cls, instance):
"""
Return a list of text labels for the given instance based on model type.
"""
labels = [str(instance)]
if instance._meta.model_name == 'device':
labels.append(f'{instance.device_type.manufacturer} {instance.device_type}')
location_label = f'{instance.site}'
if instance.location:
location_label += f' / {instance.location}'
if instance.rack:
location_label += f' / {instance.rack}'
labels.append(location_label)
elif instance._meta.model_name == 'circuit':
labels[0] = f'Circuit {instance}'
labels.append(instance.provider)
elif instance._meta.model_name == 'circuittermination':
if instance.xconnect_id:
labels.append(f'{instance.xconnect_id}')
elif instance._meta.model_name == 'providernetwork':
labels.append(instance.provider)
return labels
@classmethod
def _get_color(cls, instance):
"""
Return the appropriate fill color for an object within a cable path.
"""
if hasattr(instance, 'parent_object'):
# Termination
return 'f0f0f0'
if hasattr(instance, 'device_role'):
# Device
return instance.device_role.color
else:
# Other parent object
return 'e0e0e0'
def _draw_box(self, width, color, url, labels, y_indent=0, padding_multiplier=1, radius=10):
"""
Return an SVG Link element containing a Rect and one or more text labels representing a
parent object or cable termination point.
:param width: Box width
:param color: Box fill color
:param url: Hyperlink URL
:param labels: Iterable of text labels
:param y_indent: Vertical indent (for overlapping other boxes) (default: 0)
:param padding_multiplier: Add extra vertical padding (default: 1)
:param radius: Box corner radius (default: 10)
"""
self.cursor -= y_indent
# Create a hyperlink
link = Hyperlink(href=f'{self.base_url}{url}', target='_blank')
# Add the box
position = (
OFFSET + (self.width - width) / 2,
self.cursor
)
height = PADDING * padding_multiplier \
+ LINE_HEIGHT * len(labels) \
+ PADDING * padding_multiplier
box = Rect(position, (width - 2, height), rx=radius, class_='parent-object', style=f'fill: #{color}')
link.add(box)
self.cursor += PADDING * padding_multiplier
# Add text label(s)
for i, label in enumerate(labels):
self.cursor += LINE_HEIGHT
text_coords = (self.center, self.cursor - LINE_HEIGHT / 2)
text_color = f'#{foreground_color(color, dark="303030")}'
text = Text(label, insert=text_coords, fill=text_color, class_='bold' if not i else [])
link.add(text)
self.cursor += PADDING * padding_multiplier
return link
def _draw_cable(self, color, url, labels):
"""
Return an SVG group containing a line element and text labels representing a Cable.
:param color: Cable (line) color
:param url: Hyperlink URL
:param labels: Iterable of text labels
"""
group = Group(class_='connector')
# Draw a "shadow" line to give the cable a border
start = (OFFSET + self.center, self.cursor)
height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2
end = (start[0], start[1] + height)
cable_shadow = Line(start=start, end=end, class_='cable-shadow')
group.add(cable_shadow)
# Draw the cable
cable = Line(start=start, end=end, style=f'stroke: #{color}')
group.add(cable)
self.cursor += PADDING * 2
# Add link
link = Hyperlink(href=f'{self.base_url}{url}', target='_blank')
# Add text label(s)
for i, label in enumerate(labels):
self.cursor += LINE_HEIGHT
text_coords = (self.center + PADDING * 2, self.cursor - LINE_HEIGHT / 2)
text = Text(label, insert=text_coords, class_='bold' if not i else [])
link.add(text)
group.add(link)
self.cursor += PADDING * 2
return group
def _draw_wirelesslink(self, url, labels):
"""
Draw a line with labels representing a WirelessLink.
:param url: Hyperlink URL
:param labels: Iterable of text labels
"""
group = Group(class_='connector')
# Draw the wireless link
start = (OFFSET + self.center, self.cursor)
height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2
end = (start[0], start[1] + height)
line = Line(start=start, end=end, class_='wireless-link')
group.add(line)
self.cursor += PADDING * 2
# Add link
link = Hyperlink(href=f'{self.base_url}{url}', target='_blank')
# Add text label(s)
for i, label in enumerate(labels):
self.cursor += LINE_HEIGHT
text_coords = (self.center + PADDING * 2, self.cursor - LINE_HEIGHT / 2)
text = Text(label, insert=text_coords, class_='bold' if not i else [])
link.add(text)
group.add(link)
self.cursor += PADDING * 2
return group
def _draw_attachment(self):
"""
Return an SVG group containing a line element and "Attachment" label.
"""
group = Group(class_='connector')
# Draw attachment (line)
start = (OFFSET + self.center, OFFSET + self.cursor)
height = PADDING * 2 + LINE_HEIGHT + PADDING * 2
end = (start[0], start[1] + height)
line = Line(start=start, end=end, class_='attachment')
group.add(line)
self.cursor += PADDING * 4
return group
def render(self):
"""
Return an SVG document representing a cable trace.
"""
from dcim.models import Cable
from wireless.models import WirelessLink
traced_path = self.origin.trace()
# Prep elements list
parent_objects = []
terminations = []
connectors = []
# Iterate through each (term, cable, term) segment in the path
for i, segment in enumerate(traced_path):
near_end, connector, far_end = segment
# Near end parent
if i == 0:
# If this is the first segment, draw the originating termination's parent object
parent_object = self._draw_box(
width=self.width,
color=self._get_color(near_end.parent_object),
url=near_end.parent_object.get_absolute_url(),
labels=self._get_labels(near_end.parent_object),
padding_multiplier=2
)
parent_objects.append(parent_object)
# Near end termination
if near_end is not None:
termination = self._draw_box(
width=self.width * .8,
color=self._get_color(near_end),
url=near_end.get_absolute_url(),
labels=self._get_labels(near_end),
y_indent=PADDING,
radius=5
)
terminations.append(termination)
# Connector (a Cable or WirelessLink)
if connector is not None:
# Cable
if type(connector) is Cable:
connector_labels = [
f'Cable {connector}',
connector.get_status_display()
]
if connector.type:
connector_labels.append(connector.get_type_display())
if connector.length and connector.length_unit:
connector_labels.append(f'{connector.length} {connector.get_length_unit_display()}')
cable = self._draw_cable(
color=connector.color or '000000',
url=connector.get_absolute_url(),
labels=connector_labels
)
connectors.append(cable)
# WirelessLink
elif type(connector) is WirelessLink:
connector_labels = [
f'Wireless link {connector}',
connector.get_status_display()
]
if connector.ssid:
connector_labels.append(connector.ssid)
wirelesslink = self._draw_wirelesslink(
url=connector.get_absolute_url(),
labels=connector_labels
)
connectors.append(wirelesslink)
# Far end termination
termination = self._draw_box(
width=self.width * .8,
color=self._get_color(far_end),
url=far_end.get_absolute_url(),
labels=self._get_labels(far_end),
radius=5
)
terminations.append(termination)
# Far end parent
parent_object = self._draw_box(
width=self.width,
color=self._get_color(far_end.parent_object),
url=far_end.parent_object.get_absolute_url(),
labels=self._get_labels(far_end.parent_object),
y_indent=PADDING,
padding_multiplier=2
)
parent_objects.append(parent_object)
elif far_end:
# Attachment
attachment = self._draw_attachment()
connectors.append(attachment)
# ProviderNetwork
parent_object = self._draw_box(
width=self.width,
color=self._get_color(far_end),
url=far_end.get_absolute_url(),
labels=self._get_labels(far_end),
padding_multiplier=2
)
parent_objects.append(parent_object)
# Determine drawing size
self.drawing = svgwrite.Drawing(
size=(self.width, self.cursor + 2)
)
# Attach CSS stylesheet
with open(f'{settings.STATIC_ROOT}/cable_trace.css') as css_file:
self.drawing.defs.add(self.drawing.style(css_file.read()))
# Add elements to the drawing in order of depth (Z axis)
for element in connectors + parent_objects + terminations:
self.drawing.add(element)
return self.drawing

View File

@@ -0,0 +1,2 @@
from .cables import *
from .racks import *

399
netbox/dcim/svg/cables.py Normal file
View File

@@ -0,0 +1,399 @@
import svgwrite
from svgwrite.container import Group, Hyperlink
from svgwrite.shapes import Line, Polyline, Rect
from svgwrite.text import Text
from django.conf import settings
from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
from utilities.utils import foreground_color
__all__ = (
'CableTraceSVG',
)
OFFSET = 0.5
PADDING = 10
LINE_HEIGHT = 20
FANOUT_HEIGHT = 35
FANOUT_LEG_HEIGHT = 15
class Node(Hyperlink):
"""
Create a node to be represented in the SVG document as a rectangular box with a hyperlink.
Arguments:
position: (x, y) coordinates of the box's top left corner
width: Box width
url: Hyperlink URL
color: Box fill color (RRGGBB format)
labels: An iterable of text strings. Each label will render on a new line within the box.
radius: Box corner radius, for rounded corners (default: 10)
"""
def __init__(self, position, width, url, color, labels, radius=10, **extra):
super(Node, self).__init__(href=url, target='_blank', **extra)
x, y = position
# Add the box
dimensions = (width - 2, PADDING + LINE_HEIGHT * len(labels) + PADDING)
box = Rect((x + OFFSET, y), dimensions, rx=radius, class_='parent-object', style=f'fill: #{color}')
self.add(box)
cursor = y + PADDING
# Add text label(s)
for i, label in enumerate(labels):
cursor += LINE_HEIGHT
text_coords = (x + width / 2, cursor - LINE_HEIGHT / 2)
text_color = f'#{foreground_color(color, dark="303030")}'
text = Text(label, insert=text_coords, fill=text_color, class_='bold' if not i else [])
self.add(text)
@property
def box(self):
return self.elements[0] if self.elements else None
@property
def top_center(self):
return self.box['x'] + self.box['width'] / 2, self.box['y']
@property
def bottom_center(self):
return self.box['x'] + self.box['width'] / 2, self.box['y'] + self.box['height']
class Connector(Group):
"""
Return an SVG group containing a line element and text labels representing a Cable.
Arguments:
color: Cable (line) color
url: Hyperlink URL
labels: Iterable of text labels
"""
def __init__(self, start, url, color, labels=[], **extra):
super().__init__(class_='connector', **extra)
self.start = start
self.height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2
self.end = (start[0], start[1] + self.height)
self.color = color or '000000'
# Draw a "shadow" line to give the cable a border
cable_shadow = Line(start=self.start, end=self.end, class_='cable-shadow')
self.add(cable_shadow)
# Draw the cable
cable = Line(start=self.start, end=self.end, style=f'stroke: #{self.color}')
self.add(cable)
# Add link
link = Hyperlink(href=url, target='_blank')
# Add text label(s)
cursor = start[1]
cursor += PADDING * 2
for i, label in enumerate(labels):
cursor += LINE_HEIGHT
text_coords = (start[0] + PADDING * 2, cursor - LINE_HEIGHT / 2)
text = Text(label, insert=text_coords, class_='bold' if not i else [])
link.add(text)
self.add(link)
class CableTraceSVG:
"""
Generate a graphical representation of a CablePath in SVG format.
:param origin: The originating termination
:param width: Width of the generated image (in pixels)
:param base_url: Base URL for links within the SVG document. If none, links will be relative.
"""
def __init__(self, origin, width=CABLE_TRACE_SVG_DEFAULT_WIDTH, base_url=None):
self.origin = origin
self.width = width
self.base_url = base_url.rstrip('/') if base_url is not None else ''
# Establish a cursor to track position on the y axis
# Center edges on pixels to render sharp borders
self.cursor = OFFSET
# Prep elements lists
self.parent_objects = []
self.terminations = []
self.connectors = []
@property
def center(self):
return self.width / 2
@classmethod
def _get_labels(cls, instance):
"""
Return a list of text labels for the given instance based on model type.
"""
labels = [str(instance)]
if instance._meta.model_name == 'device':
labels.append(f'{instance.device_type.manufacturer} {instance.device_type}')
location_label = f'{instance.site}'
if instance.location:
location_label += f' / {instance.location}'
if instance.rack:
location_label += f' / {instance.rack}'
labels.append(location_label)
elif instance._meta.model_name == 'circuit':
labels[0] = f'Circuit {instance}'
labels.append(instance.provider)
elif instance._meta.model_name == 'circuittermination':
if instance.xconnect_id:
labels.append(f'{instance.xconnect_id}')
elif instance._meta.model_name == 'providernetwork':
labels.append(instance.provider)
return labels
@classmethod
def _get_color(cls, instance):
"""
Return the appropriate fill color for an object within a cable path.
"""
if hasattr(instance, 'parent_object'):
# Termination
return 'f0f0f0'
if hasattr(instance, 'device_role'):
# Device
return instance.device_role.color
else:
# Other parent object
return 'e0e0e0'
def draw_parent_objects(self, obj_list):
"""
Draw a set of parent objects.
"""
width = self.width / len(obj_list)
for i, obj in enumerate(obj_list):
node = Node(
position=(i * width, self.cursor),
width=width,
url=f'{self.base_url}{obj.get_absolute_url()}',
color=self._get_color(obj),
labels=self._get_labels(obj)
)
self.parent_objects.append(node)
if i + 1 == len(obj_list):
self.cursor += node.box['height']
def draw_terminations(self, terminations):
"""
Draw a row of terminating objects (e.g. interfaces), all of which are attached to the same end of a cable.
"""
nodes = []
nodes_height = 0
width = self.width / len(terminations)
for i, term in enumerate(terminations):
node = Node(
position=(i * width, self.cursor),
width=width,
url=f'{self.base_url}{term.get_absolute_url()}',
color=self._get_color(term),
labels=self._get_labels(term),
radius=5
)
nodes_height = max(nodes_height, node.box['height'])
nodes.append(node)
self.cursor += nodes_height
self.terminations.extend(nodes)
return nodes
def draw_fanin(self, node, connector):
points = (
node.bottom_center,
(node.bottom_center[0], node.bottom_center[1] + FANOUT_LEG_HEIGHT),
connector.start,
)
self.connectors.extend((
Polyline(points=points, class_='cable-shadow'),
Polyline(points=points, style=f'stroke: #{connector.color}'),
))
def draw_fanout(self, node, connector):
points = (
connector.end,
(node.top_center[0], node.top_center[1] - FANOUT_LEG_HEIGHT),
node.top_center,
)
self.connectors.extend((
Polyline(points=points, class_='cable-shadow'),
Polyline(points=points, style=f'stroke: #{connector.color}'),
))
def draw_cable(self, cable):
labels = [
f'Cable {cable}',
cable.get_status_display()
]
if cable.type:
labels.append(cable.get_type_display())
if cable.length and cable.length_unit:
labels.append(f'{cable.length} {cable.get_length_unit_display()}')
connector = Connector(
start=(self.center + OFFSET, self.cursor),
color=cable.color or '000000',
url=f'{self.base_url}{cable.get_absolute_url()}',
labels=labels
)
self.cursor += connector.height
return connector
def draw_wirelesslink(self, wirelesslink):
"""
Draw a line with labels representing a WirelessLink.
"""
group = Group(class_='connector')
labels = [
f'Wireless link {wirelesslink}',
wirelesslink.get_status_display()
]
if wirelesslink.ssid:
labels.append(wirelesslink.ssid)
# Draw the wireless link
start = (OFFSET + self.center, self.cursor)
height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2
end = (start[0], start[1] + height)
line = Line(start=start, end=end, class_='wireless-link')
group.add(line)
self.cursor += PADDING * 2
# Add link
link = Hyperlink(href=f'{self.base_url}{wirelesslink.get_absolute_url()}', target='_blank')
# Add text label(s)
for i, label in enumerate(labels):
self.cursor += LINE_HEIGHT
text_coords = (self.center + PADDING * 2, self.cursor - LINE_HEIGHT / 2)
text = Text(label, insert=text_coords, class_='bold' if not i else [])
link.add(text)
group.add(link)
self.cursor += PADDING * 2
return group
def draw_attachment(self):
"""
Return an SVG group containing a line element and "Attachment" label.
"""
group = Group(class_='connector')
# Draw attachment (line)
start = (OFFSET + self.center, OFFSET + self.cursor)
height = PADDING * 2 + LINE_HEIGHT + PADDING * 2
end = (start[0], start[1] + height)
line = Line(start=start, end=end, class_='attachment')
group.add(line)
self.cursor += PADDING * 4
return group
def render(self):
"""
Return an SVG document representing a cable trace.
"""
from dcim.models import Cable
from wireless.models import WirelessLink
traced_path = self.origin.trace()
# Iterate through each (terms, cable, terms) segment in the path
for i, segment in enumerate(traced_path):
near_ends, links, far_ends = segment
# Near end parent
if i == 0:
# If this is the first segment, draw the originating termination's parent object
self.draw_parent_objects(set(end.parent_object for end in near_ends))
# Near end termination(s)
terminations = self.draw_terminations(near_ends)
# Connector (a Cable or WirelessLink)
if links:
link = links[0] # Remove Cable from list
# Cable
if type(link) is Cable:
# Account for fan-ins height
if len(near_ends) > 1:
self.cursor += FANOUT_HEIGHT
cable = self.draw_cable(link)
self.connectors.append(cable)
# Draw fan-ins
if len(near_ends) > 1:
for term in terminations:
self.draw_fanin(term, cable)
# WirelessLink
elif type(link) is WirelessLink:
wirelesslink = self.draw_wirelesslink(link)
self.connectors.append(wirelesslink)
# Far end termination(s)
if len(far_ends) > 1:
self.cursor += FANOUT_HEIGHT
terminations = self.draw_terminations(far_ends)
for term in terminations:
self.draw_fanout(term, cable)
elif far_ends:
self.draw_terminations(far_ends)
else:
# Link is not connected to anything
break
# Far end parent
parent_objects = set(end.parent_object for end in far_ends)
self.draw_parent_objects(parent_objects)
# Render a far-end object not connected via a link (e.g. a ProviderNetwork or Site associated with
# a CircuitTermination)
elif far_ends:
# Attachment
attachment = self.draw_attachment()
self.connectors.append(attachment)
# Object
self.draw_parent_objects(far_ends)
# Determine drawing size
self.drawing = svgwrite.Drawing(
size=(self.width, self.cursor + 2)
)
# Attach CSS stylesheet
with open(f'{settings.STATIC_ROOT}/cable_trace.css') as css_file:
self.drawing.defs.add(self.drawing.style(css_file.read()))
# Add elements to the drawing in order of depth (Z axis)
for element in self.connectors + self.parent_objects + self.terminations:
self.drawing.add(element)
return self.drawing

323
netbox/dcim/svg/racks.py Normal file
View File

@@ -0,0 +1,323 @@
import decimal
import svgwrite
from svgwrite.container import Hyperlink
from svgwrite.image import Image
from svgwrite.gradients import LinearGradient
from svgwrite.shapes import Rect
from svgwrite.text import Text
from django.conf import settings
from django.core.exceptions import FieldError
from django.db.models import Q
from django.urls import reverse
from django.utils.http import urlencode
from netbox.config import get_config
from utilities.utils import foreground_color, array_to_ranges
from dcim.constants import RACK_ELEVATION_BORDER_WIDTH
__all__ = (
'RackElevationSVG',
)
def get_device_name(device):
if device.virtual_chassis:
name = f'{device.virtual_chassis.name}:{device.vc_position}'
elif device.name:
name = device.name
else:
name = str(device.device_type)
if device.devicebay_count:
name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count)
return name
def get_device_description(device):
return '{} ({}) — {} {} ({}U) {} {}'.format(
device.name,
device.device_role,
device.device_type.manufacturer.name,
device.device_type.model,
device.device_type.u_height,
device.asset_tag or '',
device.serial or ''
)
class RackElevationSVG:
"""
Use this class to render a rack elevation as an SVG image.
:param rack: A NetBox Rack instance
:param unit_width: Rendered unit width, in pixels
:param unit_height: Rendered unit height, in pixels
:param legend_width: Legend width, in pixels (where the unit labels appear)
:param margin_width: Margin width, in pixels (where reservations appear)
:param user: User instance. If specified, only devices viewable by this user will be fully displayed.
:param include_images: If true, the SVG document will embed front/rear device face images, where available
:param base_url: Base URL for links within the SVG document. If none, links will be relative.
:param highlight_params: Iterable of two-tuples which identifies attributes of devices to highlight
"""
def __init__(self, rack, unit_height=None, unit_width=None, legend_width=None, margin_width=None, user=None,
include_images=True, base_url=None, highlight_params=None):
self.rack = rack
self.include_images = include_images
self.base_url = base_url.rstrip('/') if base_url is not None else ''
# Set drawing dimensions
config = get_config()
self.unit_width = unit_width or config.RACK_ELEVATION_DEFAULT_UNIT_WIDTH
self.unit_height = unit_height or config.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT
self.legend_width = legend_width or config.RACK_ELEVATION_DEFAULT_LEGEND_WIDTH
self.margin_width = margin_width or config.RACK_ELEVATION_DEFAULT_MARGIN_WIDTH
# Determine the subset of devices within this rack that are viewable by the user, if any
permitted_devices = self.rack.devices
if user is not None:
permitted_devices = permitted_devices.restrict(user, 'view')
self.permitted_device_ids = permitted_devices.values_list('pk', flat=True)
# Determine device(s) to highlight within the elevation (if any)
self.highlight_devices = []
if highlight_params:
q = Q()
for k, v in highlight_params:
q |= Q(**{k: v})
try:
self.highlight_devices = permitted_devices.filter(q)
except FieldError:
pass
@staticmethod
def _add_gradient(drawing, id_, color):
gradient = 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)
def _setup_drawing(self):
width = self.unit_width + self.legend_width + self.margin_width + RACK_ELEVATION_BORDER_WIDTH * 2
height = self.unit_height * self.rack.u_height + RACK_ELEVATION_BORDER_WIDTH * 2
drawing = svgwrite.Drawing(size=(width, height))
# Add the stylesheet
with open(f'{settings.STATIC_ROOT}/rack_elevation.css') as css_file:
drawing.defs.add(drawing.style(css_file.read()))
# Add gradients
RackElevationSVG._add_gradient(drawing, 'reserved', '#b0b0ff')
RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7')
RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0')
return drawing
def _get_device_coords(self, position, height):
"""
Return the X, Y coordinates of the top left corner for a device in the specified rack unit.
"""
x = self.legend_width + RACK_ELEVATION_BORDER_WIDTH
y = RACK_ELEVATION_BORDER_WIDTH
if self.rack.desc_units:
y += int((position - 1) * self.unit_height)
else:
y += int((self.rack.u_height - position + 1) * self.unit_height) - int(height * self.unit_height)
return x, y
def _draw_device(self, device, coords, size, color=None, image=None):
name = get_device_name(device)
description = get_device_description(device)
text_color = f'#{foreground_color(color)}' if color else '#000000'
text_coords = (
coords[0] + size[0] / 2,
coords[1] + size[1] / 2
)
# Determine whether highlighting is in use, and if so, whether to shade this device
is_shaded = self.highlight_devices and device not in self.highlight_devices
css_extra = ' shaded' if is_shaded else ''
# Create hyperlink element
link = Hyperlink(href=f'{self.base_url}{device.get_absolute_url()}', target='_blank')
link.set_desc(description)
# Add rect element to hyperlink
if color:
link.add(Rect(coords, size, style=f'fill: #{color}', class_=f'slot{css_extra}'))
else:
link.add(Rect(coords, size, class_=f'slot blocked{css_extra}'))
link.add(Text(name, insert=text_coords, fill=text_color, class_=f'label{css_extra}'))
# Embed device type image if provided
if self.include_images and image:
url = f'{self.base_url}{image.url}' if image.url.startswith('/') else image.url
image = Image(
href=url,
insert=coords,
size=size,
class_=f'device-image{css_extra}'
)
image.fit(scale='slice')
link.add(image)
link.add(
Text(name, insert=text_coords, stroke='black', stroke_width='0.2em', stroke_linejoin='round',
class_=f'device-image-label{css_extra}')
)
link.add(
Text(name, insert=text_coords, fill='white', class_=f'device-image-label{css_extra}')
)
self.drawing.add(link)
def draw_device_front(self, device, coords, size):
"""
Draw the front (mounted) face of a device.
"""
color = device.device_role.color
image = device.device_type.front_image
self._draw_device(device, coords, size, color=color, image=image)
def draw_device_rear(self, device, coords, size):
"""
Draw the rear (opposite) face of a device.
"""
image = device.device_type.rear_image
self._draw_device(device, coords, size, image=image)
def draw_border(self):
"""
Draw a border around the collection of rack units.
"""
border_width = RACK_ELEVATION_BORDER_WIDTH
border_offset = RACK_ELEVATION_BORDER_WIDTH / 2
frame = Rect(
insert=(self.legend_width + border_offset, border_offset),
size=(self.unit_width + border_width, self.rack.u_height * self.unit_height + border_width),
class_='rack'
)
self.drawing.add(frame)
def draw_legend(self):
"""
Draw the rack unit labels along the lefthand side of the elevation.
"""
for ru in range(0, self.rack.u_height):
start_y = ru * self.unit_height + RACK_ELEVATION_BORDER_WIDTH
position_coordinates = (self.legend_width / 2, start_y + self.unit_height / 2 + RACK_ELEVATION_BORDER_WIDTH)
unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru
self.drawing.add(
Text(str(unit), position_coordinates, class_='unit')
)
def draw_margin(self):
"""
Draw any rack reservations in the right-hand margin alongside the rack elevation.
"""
for reservation in self.rack.reservations.all():
for segment in array_to_ranges(reservation.units):
u_height = 1 if len(segment) == 1 else segment[1] + 1 - segment[0]
coords = self._get_device_coords(segment[0], u_height)
coords = (coords[0] + self.unit_width + RACK_ELEVATION_BORDER_WIDTH * 2, coords[1])
size = (
self.margin_width,
u_height * self.unit_height
)
link = Hyperlink(
href='{}{}'.format(self.base_url, reservation.get_absolute_url()),
target='_blank'
)
link.set_desc(f'Reservation #{reservation.pk}: {reservation.description}')
link.add(
Rect(coords, size, class_='reservation')
)
self.drawing.add(link)
def draw_background(self, face):
"""
Draw the rack unit placeholders which form the "background" of the rack elevation.
"""
x_offset = RACK_ELEVATION_BORDER_WIDTH + self.legend_width
url_string = '{}?{}&position={{}}'.format(
reverse('dcim:device_add'),
urlencode({
'site': self.rack.site.pk,
'location': self.rack.location.pk if self.rack.location else '',
'rack': self.rack.pk,
'face': face,
})
)
for ru in range(0, self.rack.u_height):
unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru
y_offset = RACK_ELEVATION_BORDER_WIDTH + ru * self.unit_height
text_coords = (
x_offset + self.unit_width / 2,
y_offset + self.unit_height / 2
)
link = Hyperlink(href=url_string.format(unit), target='_blank')
link.add(Rect((x_offset, y_offset), (self.unit_width, self.unit_height), class_='slot'))
link.add(Text('add device', insert=text_coords, class_='add-device'))
self.drawing.add(link)
def draw_face(self, face, opposite=False):
"""
Draw any occupied rack units for the specified rack face.
"""
for unit in self.rack.get_rack_units(face=face, expand_devices=False):
# Loop through all units in the elevation
device = unit['device']
height = unit.get('height', decimal.Decimal(1.0))
device_coords = self._get_device_coords(unit['id'], height)
device_size = (
self.unit_width,
int(self.unit_height * height)
)
# Draw the device
if device and device.pk in self.permitted_device_ids:
if device.face == face and not opposite:
self.draw_device_front(device, device_coords, device_size)
else:
self.draw_device_rear(device, device_coords, device_size)
elif device:
# Devices which the user does not have permission to view are rendered only as unavailable space
self.drawing.add(Rect(device_coords, device_size, class_='blocked'))
def render(self, face):
"""
Return an SVG document representing a rack elevation.
"""
# Initialize the drawing
self.drawing = self._setup_drawing()
# Draw the empty rack, legend, and margin
self.draw_legend()
self.draw_background(face)
self.draw_margin()
# Draw the rack face
self.draw_face(face)
# Draw the rack border last
self.draw_border()
return self.drawing

View File

@@ -1,56 +1,109 @@
import django_tables2 as tables
from django_tables2.utils import Accessor
from django.utils.safestring import mark_safe
from dcim.models import Cable
from netbox.tables import NetBoxTable, columns
from tenancy.tables import TenancyColumnsMixin
from .template_code import CABLE_LENGTH, CABLE_TERMINATION_PARENT
from .template_code import CABLE_LENGTH
__all__ = (
'CableTable',
)
class CableTerminationsColumn(tables.Column):
"""
Args:
cable_end: Which side of the cable to report on (A or B)
attr: The CableTermination attribute to return for each instance (returns the termination object by default)
"""
def __init__(self, cable_end, attr='termination', *args, **kwargs):
self.cable_end = cable_end
self.attr = attr
super().__init__(accessor=Accessor('terminations'), *args, **kwargs)
def _get_terminations(self, manager):
terminations = set()
for cabletermination in manager.all():
if cabletermination.cable_end == self.cable_end:
if termination := getattr(cabletermination, self.attr, None):
terminations.add(termination)
return terminations
def render(self, value):
links = [
f'<a href="{term.get_absolute_url()}">{term}</a>' for term in self._get_terminations(value)
]
return mark_safe('<br />'.join(links) or '&mdash;')
def value(self, value):
return ','.join([str(t) for t in self._get_terminations(value)])
#
# Cables
#
class CableTable(TenancyColumnsMixin, NetBoxTable):
termination_a_parent = tables.TemplateColumn(
template_code=CABLE_TERMINATION_PARENT,
accessor=Accessor('termination_a'),
a_terminations = CableTerminationsColumn(
cable_end='A',
orderable=False,
verbose_name='Side A'
)
rack_a = tables.Column(
accessor=Accessor('termination_a__device__rack'),
orderable=False,
linkify=True,
verbose_name='Rack A'
)
termination_a = tables.Column(
accessor=Accessor('termination_a'),
orderable=False,
linkify=True,
verbose_name='Termination A'
)
termination_b_parent = tables.TemplateColumn(
template_code=CABLE_TERMINATION_PARENT,
accessor=Accessor('termination_b'),
b_terminations = CableTerminationsColumn(
cable_end='B',
orderable=False,
verbose_name='Side B'
verbose_name='Termination B'
)
rack_b = tables.Column(
accessor=Accessor('termination_b__device__rack'),
device_a = CableTerminationsColumn(
cable_end='A',
attr='_device',
orderable=False,
verbose_name='Device A'
)
device_b = CableTerminationsColumn(
cable_end='B',
attr='_device',
orderable=False,
verbose_name='Device B'
)
location_a = CableTerminationsColumn(
cable_end='A',
attr='_location',
orderable=False,
verbose_name='Location A'
)
location_b = CableTerminationsColumn(
cable_end='B',
attr='_location',
orderable=False,
verbose_name='Location B'
)
rack_a = CableTerminationsColumn(
cable_end='A',
attr='_rack',
orderable=False,
verbose_name='Rack A'
)
rack_b = CableTerminationsColumn(
cable_end='B',
attr='_rack',
orderable=False,
linkify=True,
verbose_name='Rack B'
)
termination_b = tables.Column(
accessor=Accessor('termination_b'),
site_a = CableTerminationsColumn(
cable_end='A',
attr='_site',
orderable=False,
linkify=True,
verbose_name='Termination B'
verbose_name='Site A'
)
site_b = CableTerminationsColumn(
cable_end='B',
attr='_site',
orderable=False,
verbose_name='Site B'
)
status = columns.ChoiceFieldColumn()
length = columns.TemplateColumn(
@@ -65,10 +118,10 @@ class CableTable(TenancyColumnsMixin, NetBoxTable):
class Meta(NetBoxTable.Meta):
model = Cable
fields = (
'pk', 'id', 'label', 'termination_a_parent', 'rack_a', 'termination_a', 'termination_b_parent', 'rack_b', 'termination_b',
'status', 'type', 'tenant', 'tenant_group', 'color', 'length', 'tags', 'created', 'last_updated',
'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'device_a', 'device_b', 'rack_a', 'rack_b',
'location_a', 'location_b', 'site_a', 'site_b', 'status', 'type', 'tenant', 'tenant_group', 'color',
'length', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b',
'status', 'type',
'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'status', 'type',
)

View File

@@ -274,17 +274,17 @@ class CableTerminationTable(NetBoxTable):
verbose_name='Cable Color'
)
link_peer = columns.TemplateColumn(
accessor='_link_peer',
accessor='link_peers',
template_code=LINKTERMINATION,
orderable=False,
verbose_name='Link Peer'
verbose_name='Link Peers'
)
mark_connected = columns.BooleanColumn()
class PathEndpointTable(CableTerminationTable):
connection = columns.TemplateColumn(
accessor='_path__last_node',
accessor='_path__destinations',
template_code=LINKTERMINATION,
verbose_name='Connection',
orderable=False
@@ -518,10 +518,10 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
model = Interface
fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu',
'speed', 'duplex', 'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency',
'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link',
'wireless_lans', 'link_peer', 'connection', 'tags', 'vrf', 'ip_addresses', 'fhrp_groups', 'untagged_vlan',
'tagged_vlans', 'created', 'last_updated',
'speed', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel',
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable',
'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vrf', 'ip_addresses',
'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')

View File

@@ -172,7 +172,7 @@ class InterfaceTemplateTable(ComponentTemplateTable):
class Meta(ComponentTemplateTable.Meta):
model = InterfaceTemplate
fields = ('pk', 'name', 'label', 'mgmt_only', 'type', 'description', 'actions')
fields = ('pk', 'name', 'label', 'mgmt_only', 'type', 'description', 'poe_mode', 'poe_type', 'actions')
empty_text = "None"

View File

@@ -14,6 +14,9 @@ class ModuleTypeTable(NetBoxTable):
linkify=True,
verbose_name='Module Type'
)
manufacturer = tables.Column(
linkify=True
)
instance_count = columns.LinkedCountColumn(
viewname='dcim:module_list',
url_params={'module_type_id': 'pk'},
@@ -41,6 +44,10 @@ class ModuleTable(NetBoxTable):
module_bay = tables.Column(
linkify=True
)
manufacturer = tables.Column(
accessor=tables.A('module_type__manufacturer'),
linkify=True
)
module_type = tables.Column(
linkify=True
)
@@ -52,8 +59,9 @@ class ModuleTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = Module
fields = (
'pk', 'id', 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'comments', 'tags',
'pk', 'id', 'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag', 'comments',
'tags',
)
default_columns = (
'pk', 'id', 'device', 'module_bay', 'module_type', 'serial', 'asset_tag',
'pk', 'id', 'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag',
)

View File

@@ -21,6 +21,9 @@ class PowerPanelTable(NetBoxTable):
site = tables.Column(
linkify=True
)
location = tables.Column(
linkify=True
)
powerfeed_count = columns.LinkedCountColumn(
viewname='dcim:powerfeed_list',
url_params={'power_panel_id': 'pk'},
@@ -35,7 +38,9 @@ class PowerPanelTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = PowerPanel
fields = ('pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'contacts', 'tags', 'created', 'last_updated',)
fields = (
'pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'contacts', 'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'site', 'location', 'powerfeed_count')

View File

@@ -109,6 +109,10 @@ class RackReservationTable(TenancyColumnsMixin, NetBoxTable):
accessor=Accessor('rack__site'),
linkify=True
)
location = tables.Column(
accessor=Accessor('rack__location'),
linkify=True
)
rack = tables.Column(
linkify=True
)
@@ -123,7 +127,7 @@ class RackReservationTable(TenancyColumnsMixin, NetBoxTable):
class Meta(NetBoxTable.Meta):
model = RackReservation
fields = (
'pk', 'id', 'reservation', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'tenant_group', 'description', 'tags',
'pk', 'id', 'reservation', 'site', 'location', 'rack', 'unit_list', 'user', 'created', 'tenant', 'tenant_group', 'description', 'tags',
'actions', 'created', 'last_updated',
)
default_columns = ('pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description')

View File

@@ -125,6 +125,7 @@ class LocationTable(TenancyColumnsMixin, NetBoxTable):
site = tables.Column(
linkify=True
)
status = columns.ChoiceFieldColumn()
rack_count = columns.LinkedCountColumn(
viewname='dcim:rack_list',
url_params={'location_id': 'pk'},
@@ -148,7 +149,7 @@ class LocationTable(TenancyColumnsMixin, NetBoxTable):
class Meta(NetBoxTable.Meta):
model = Location
fields = (
'pk', 'id', 'name', 'site', 'tenant', 'tenant_group', 'rack_count', 'device_count', 'description', 'slug', 'contacts',
'tags', 'actions', 'created', 'last_updated',
'pk', 'id', 'name', 'site', 'status', 'tenant', 'tenant_group', 'rack_count', 'device_count', 'description',
'slug', 'contacts', 'tags', 'actions', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description')
default_columns = ('pk', 'name', 'site', 'status', 'tenant', 'rack_count', 'device_count', 'description')

View File

@@ -1,11 +1,9 @@
LINKTERMINATION = """
{% if value %}
{% if value.parent_object %}
<a href="{{ value.parent_object.get_absolute_url }}">{{ value.parent_object }}</a>
<i class="mdi mdi-chevron-right"></i>
{% endif %}
<a href="{{ value.get_absolute_url }}">{{ value }}</a>
{% endif %}
{% for termination in value %}
<a href="{{ termination.get_absolute_url }}">{{ termination }}</a>{% if not forloop.last %},{% endif %}
{% empty %}
{{ ''|placeholder }}
{% endfor %}
"""
CABLE_LENGTH = """
@@ -13,16 +11,6 @@ CABLE_LENGTH = """
{% if record.length %}{{ record.length|simplify_decimal }} {{ record.length_unit }}{% endif %}
"""
CABLE_TERMINATION_PARENT = """
{% if value.device %}
<a href="{{ value.device.get_absolute_url }}">{{ value.device }}</a>
{% elif value.circuit %}
<a href="{{ value.circuit.get_absolute_url }}">{{ value.circuit }}</a>
{% elif value.power_panel %}
<a href="{{ value.power_panel.get_absolute_url }}">{{ value.power_panel }}</a>
{% endif %}
"""
DEVICE_LINK = """
<a href="{% url 'dcim:device' pk=record.pk %}">
{{ record.name|default:'<span class="badge bg-info">Unnamed device</span>' }}
@@ -133,9 +121,9 @@ CONSOLEPORT_BUTTONS = """
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="{% url 'dcim:consoleport_connect' termination_a_id=record.pk termination_b_type='console-server-port' %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}">Console Server Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:consoleport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}">Front Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:consoleport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}">Rear Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleport&a_terminations={{ record.pk }}&b_terminations_type=dcim.consoleserverport&return_url={% url 'dcim:device_consoleports' pk=object.pk %}">Console Server Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleport&a_terminations={{ record.pk }}&b_terminations_type=dcim.frontport&return_url={% url 'dcim:device_consoleports' pk=object.pk %}">Front Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleport&a_terminations={{ record.pk }}&b_terminations_type=dcim.rearport&return_url={% url 'dcim:device_consoleports' pk=object.pk %}">Rear Port</a></li>
</ul>
</span>
{% else %}
@@ -165,9 +153,9 @@ CONSOLESERVERPORT_BUTTONS = """
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="{% url 'dcim:consoleserverport_connect' termination_a_id=record.pk termination_b_type='console-port' %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}">Console Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:consoleserverport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}">Front Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:consoleserverport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}">Rear Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleserverport&a_terminations={{ record.pk }}&b_terminations_type=dcim.consoleport&return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}">Console Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleserverport&a_terminations={{ record.pk }}&b_terminations_type=dcim.frontport&return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}">Front Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleserverport&a_terminations={{ record.pk }}&b_terminations_type=dcim.rearport&return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}">Rear Port</a></li>
</ul>
</span>
{% else %}
@@ -197,8 +185,8 @@ POWERPORT_BUTTONS = """
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="{% url 'dcim:powerport_connect' termination_a_id=record.pk termination_b_type='power-outlet' %}?return_url={% url 'dcim:device_powerports' pk=object.pk %}">Power Outlet</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:powerport_connect' termination_a_id=record.pk termination_b_type='power-feed' %}?return_url={% url 'dcim:device_powerports' pk=object.pk %}">Power Feed</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerport&a_terminations={{ record.pk }}&b_terminations_type=dcim.poweroutlet&return_url={% url 'dcim:device_powerports' pk=object.pk %}">Power Outlet</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerport&a_terminations={{ record.pk }}&b_terminations_type=dcim.powerfeed&return_url={% url 'dcim:device_powerports' pk=object.pk %}">Power Feed</a></li>
</ul>
</span>
{% else %}
@@ -224,7 +212,7 @@ POWEROUTLET_BUTTONS = """
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
{% if not record.mark_connected %}
<a href="{% url 'dcim:poweroutlet_connect' termination_a_id=record.pk termination_b_type='power-port' %}?return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" title="Connect" class="btn btn-success btn-sm">
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.poweroutlet&a_terminations={{ record.pk }}&b_terminations_type=dcim.powerport&return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" title="Connect" class="btn btn-success btn-sm">
<i class="mdi mdi-ethernet-cable" aria-hidden="true"></i>
</a>
{% else %}
@@ -274,10 +262,10 @@ INTERFACE_BUTTONS = """
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="{% url 'dcim:interface_connect' termination_a_id=record.pk termination_b_type='interface' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Interface</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:interface_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Front Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:interface_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Rear Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:interface_connect' termination_a_id=record.pk termination_b_type='circuit-termination' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Circuit Termination</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.interface&a_terminations={{ record.pk }}&b_terminations_type=dcim.interface&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Interface</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.interface&a_terminations={{ record.pk }}&b_terminations_type=dcim.frontport&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Front Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.interface&a_terminations={{ record.pk }}&b_terminations_type=dcim.rearport&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Rear Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.interface&a_terminations={{ record.pk }}&b_terminations_type=circuits.circuittermination&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Circuit Termination</a></li>
</ul>
</span>
{% else %}
@@ -313,12 +301,12 @@ FRONTPORT_BUTTONS = """
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='interface' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Interface</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='console-server-port' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Console Server Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='console-port' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Console Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Front Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Rear Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='circuit-termination' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Circuit Termination</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ record.pk }}&b_terminations_type=dcim.interface&return_url={% url 'dcim:device_frontports' pk=object.pk %}">Interface</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ record.pk }}&b_terminations_type=dcim.consoleserverport&return_url={% url 'dcim:device_frontports' pk=object.pk %}">Console Server Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ record.pk }}&b_terminations_type=dcim.consoleport&return_url={% url 'dcim:device_frontports' pk=object.pk %}">Console Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ record.pk }}&b_terminations_type=dcim.frontport&return_url={% url 'dcim:device_frontports' pk=object.pk %}">Front Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ record.pk }}&b_terminations_type=dcim.rearport&return_url={% url 'dcim:device_frontports' pk=object.pk %}">Rear Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ record.pk }}&b_terminations_type=circuits.circuittermination&return_url={% url 'dcim:device_frontports' pk=object.pk %}">Circuit Termination</a></li>
</ul>
</span>
{% else %}
@@ -350,12 +338,12 @@ REARPORT_BUTTONS = """
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='interface' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Interface</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='console-server-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Console Server Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='console-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Console Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Front Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Rear Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='circuit-termination' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Circuit Termination</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ record.pk }}&b_terminations_type=dcim.interface&return_url={% url 'dcim:device_rearports' pk=object.pk %}">Interface</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ record.pk }}&b_terminations_type=dcim.consoleserverport&return_url={% url 'dcim:device_rearports' pk=object.pk %}">Console Server Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ record.pk }}&b_terminations_type=dcim.consoleport&return_url={% url 'dcim:device_rearports' pk=object.pk %}">Console Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ record.pk }}&b_terminations_type=dcim.frontport&return_url={% url 'dcim:device_rearports' pk=object.pk %}">Front Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ record.pk }}&b_terminations_type=dcim.rearport&return_url={% url 'dcim:device_rearports' pk=object.pk %}">Rear Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ record.pk }}&b_terminations_type=circuits.circuittermination&return_url={% url 'dcim:device_rearports' pk=object.pk %}">Circuit Termination</a></li>
</ul>
</span>
{% else %}

View File

@@ -7,6 +7,7 @@ from dcim.choices import *
from dcim.constants import *
from dcim.models import *
from ipam.models import ASN, RIR, VLAN, VRF
from netbox.api.serializers import GenericObjectSerializer
from utilities.testing import APITestCase, APIViewTestCases, create_test_device
from virtualization.models import Cluster, ClusterType
from wireless.choices import WirelessChannelChoices
@@ -45,7 +46,7 @@ class Mixins:
device=peer_device,
name='Peer Termination'
)
cable = Cable(termination_a=obj, termination_b=peer_obj, label='Cable 1')
cable = Cable(a_terminations=[obj], b_terminations=[peer_obj], label='Cable 1')
cable.save()
self.add_permissions(f'dcim.view_{self.model._meta.model_name}')
@@ -55,9 +56,9 @@ class Mixins:
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
segment1 = response.data[0]
self.assertEqual(segment1[0]['name'], obj.name)
self.assertEqual(segment1[0][0]['name'], obj.name)
self.assertEqual(segment1[1]['label'], cable.label)
self.assertEqual(segment1[2]['name'], peer_obj.name)
self.assertEqual(segment1[2][0]['name'], peer_obj.name)
class RegionTest(APIViewTestCases.APIViewTestCase):
@@ -197,13 +198,13 @@ class LocationTest(APIViewTestCases.APIViewTestCase):
Site.objects.bulk_create(sites)
parent_locations = (
Location.objects.create(site=sites[0], name='Parent Location 1', slug='parent-location-1'),
Location.objects.create(site=sites[1], name='Parent Location 2', slug='parent-location-2'),
Location.objects.create(site=sites[0], name='Parent Location 1', slug='parent-location-1', status=LocationStatusChoices.STATUS_ACTIVE),
Location.objects.create(site=sites[1], name='Parent Location 2', slug='parent-location-2', status=LocationStatusChoices.STATUS_ACTIVE),
)
Location.objects.create(site=sites[0], name='Location 1', slug='location-1', parent=parent_locations[0])
Location.objects.create(site=sites[0], name='Location 2', slug='location-2', parent=parent_locations[0])
Location.objects.create(site=sites[0], name='Location 3', slug='location-3', parent=parent_locations[0])
Location.objects.create(site=sites[0], name='Location 1', slug='location-1', parent=parent_locations[0], status=LocationStatusChoices.STATUS_ACTIVE)
Location.objects.create(site=sites[0], name='Location 2', slug='location-2', parent=parent_locations[0], status=LocationStatusChoices.STATUS_ACTIVE)
Location.objects.create(site=sites[0], name='Location 3', slug='location-3', parent=parent_locations[0], status=LocationStatusChoices.STATUS_ACTIVE)
cls.create_data = [
{
@@ -211,18 +212,21 @@ class LocationTest(APIViewTestCases.APIViewTestCase):
'slug': 'test-location-4',
'site': sites[1].pk,
'parent': parent_locations[1].pk,
'status': LocationStatusChoices.STATUS_PLANNED,
},
{
'name': 'Test Location 5',
'slug': 'test-location-5',
'site': sites[1].pk,
'parent': parent_locations[1].pk,
'status': LocationStatusChoices.STATUS_PLANNED,
},
{
'name': 'Test Location 6',
'slug': 'test-location-6',
'site': sites[1].pk,
'parent': parent_locations[1].pk,
'status': LocationStatusChoices.STATUS_PLANNED,
},
]
@@ -327,15 +331,15 @@ class RackTest(APIViewTestCases.APIViewTestCase):
# Retrieve all units
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 42)
self.assertEqual(response.data['count'], 84)
# Search for specific units
response = self.client.get(f'{url}?q=3', **self.header)
self.assertEqual(response.data['count'], 13)
self.assertEqual(response.data['count'], 26)
response = self.client.get(f'{url}?q=U3', **self.header)
self.assertEqual(response.data['count'], 11)
self.assertEqual(response.data['count'], 22)
response = self.client.get(f'{url}?q=U10', **self.header)
self.assertEqual(response.data['count'], 1)
self.assertEqual(response.data['count'], 2)
def test_get_rack_elevation_svg(self):
"""
@@ -1507,6 +1511,8 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
'speed': 1000000,
'duplex': 'full',
'vrf': vrfs[0].pk,
'poe_mode': InterfacePoEModeChoices.MODE_PD,
'poe_type': InterfacePoETypeChoices.TYPE_1_8023AF,
'tagged_vlans': [vlans[0].pk, vlans[1].pk],
'untagged_vlan': vlans[2].pk,
},
@@ -1859,6 +1865,17 @@ class CableTest(APIViewTestCases.APIViewTestCase):
# TODO: Allow updating cable terminations
test_update_object = None
def model_to_dict(self, *args, **kwargs):
data = super().model_to_dict(*args, **kwargs)
# Serialize termination objects
if 'a_terminations' in data:
data['a_terminations'] = GenericObjectSerializer(data['a_terminations'], many=True).data
if 'b_terminations' in data:
data['b_terminations'] = GenericObjectSerializer(data['b_terminations'], many=True).data
return data
@classmethod
def setUpTestData(cls):
site = Site.objects.create(name='Site 1', slug='site-1')
@@ -1879,33 +1896,45 @@ class CableTest(APIViewTestCases.APIViewTestCase):
Interface.objects.bulk_create(interfaces)
cables = (
Cable(termination_a=interfaces[0], termination_b=interfaces[10], label='Cable 1'),
Cable(termination_a=interfaces[1], termination_b=interfaces[11], label='Cable 2'),
Cable(termination_a=interfaces[2], termination_b=interfaces[12], label='Cable 3'),
Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[10]], label='Cable 1'),
Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[11]], label='Cable 2'),
Cable(a_terminations=[interfaces[2]], b_terminations=[interfaces[12]], label='Cable 3'),
)
for cable in cables:
cable.save()
cls.create_data = [
{
'termination_a_type': 'dcim.interface',
'termination_a_id': interfaces[4].pk,
'termination_b_type': 'dcim.interface',
'termination_b_id': interfaces[14].pk,
'a_terminations': [{
'object_type': 'dcim.interface',
'object_id': interfaces[4].pk,
}],
'b_terminations': [{
'object_type': 'dcim.interface',
'object_id': interfaces[14].pk,
}],
'label': 'Cable 4',
},
{
'termination_a_type': 'dcim.interface',
'termination_a_id': interfaces[5].pk,
'termination_b_type': 'dcim.interface',
'termination_b_id': interfaces[15].pk,
'a_terminations': [{
'object_type': 'dcim.interface',
'object_id': interfaces[5].pk,
}],
'b_terminations': [{
'object_type': 'dcim.interface',
'object_id': interfaces[15].pk,
}],
'label': 'Cable 5',
},
{
'termination_a_type': 'dcim.interface',
'termination_a_id': interfaces[6].pk,
'termination_b_type': 'dcim.interface',
'termination_b_id': interfaces[16].pk,
'a_terminations': [{
'object_type': 'dcim.interface',
'object_id': interfaces[6].pk,
}],
'b_terminations': [{
'object_type': 'dcim.interface',
'object_id': interfaces[16].pk,
}],
'label': 'Cable 6',
},
]
@@ -1931,7 +1960,7 @@ class ConnectedDeviceTest(APITestCase):
self.interface2 = Interface.objects.create(device=self.device2, name='eth0')
self.interface3 = Interface.objects.create(device=self.device1, name='eth1') # Not connected
cable = Cable(termination_a=self.interface1, termination_b=self.interface2)
cable = Cable(a_terminations=[self.interface1], b_terminations=[self.interface2])
cable.save()
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])

File diff suppressed because it is too large Load Diff

View File

@@ -265,9 +265,9 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests):
location.save()
locations = (
Location(name='Location 1', slug='location-1', site=sites[0], parent=parent_locations[0], description='A'),
Location(name='Location 2', slug='location-2', site=sites[1], parent=parent_locations[1], description='B'),
Location(name='Location 3', slug='location-3', site=sites[2], parent=parent_locations[2], description='C'),
Location(name='Location 1', slug='location-1', site=sites[0], parent=parent_locations[0], status=LocationStatusChoices.STATUS_PLANNED, description='A'),
Location(name='Location 2', slug='location-2', site=sites[1], parent=parent_locations[1], status=LocationStatusChoices.STATUS_STAGING, description='B'),
Location(name='Location 3', slug='location-3', site=sites[2], parent=parent_locations[2], status=LocationStatusChoices.STATUS_DECOMMISSIONING, description='C'),
)
for location in locations:
location.save()
@@ -280,6 +280,10 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'slug': ['location-1', 'location-2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_status(self):
params = {'status': [LocationStatusChoices.STATUS_PLANNED, LocationStatusChoices.STATUS_STAGING]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
params = {'description': ['A', 'B']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -1085,8 +1089,8 @@ class InterfaceTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
DeviceType.objects.bulk_create(device_types)
InterfaceTemplate.objects.bulk_create((
InterfaceTemplate(device_type=device_types[0], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED, mgmt_only=True),
InterfaceTemplate(device_type=device_types[1], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_GBIC, mgmt_only=False),
InterfaceTemplate(device_type=device_types[0], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED, mgmt_only=True, poe_mode=InterfacePoEModeChoices.MODE_PD, poe_type=InterfacePoETypeChoices.TYPE_1_8023AF),
InterfaceTemplate(device_type=device_types[1], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_GBIC, mgmt_only=False, poe_mode=InterfacePoEModeChoices.MODE_PSE, poe_type=InterfacePoETypeChoices.TYPE_2_8023AT),
InterfaceTemplate(device_type=device_types[2], name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_SFP, mgmt_only=False),
))
@@ -1109,6 +1113,14 @@ class InterfaceTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'mgmt_only': 'false'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_poe_mode(self):
params = {'poe_mode': [InterfacePoEModeChoices.MODE_PD, InterfacePoEModeChoices.MODE_PSE]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_poe_type(self):
params = {'poe_type': [InterfacePoETypeChoices.TYPE_1_8023AF, InterfacePoETypeChoices.TYPE_2_8023AT]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class FrontPortTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = FrontPortTemplate.objects.all()
@@ -1948,8 +1960,8 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
ConsolePort.objects.bulk_create(console_ports)
# Cables
Cable(termination_a=console_ports[0], termination_b=console_server_ports[0]).save()
Cable(termination_a=console_ports[1], termination_b=console_server_ports[1]).save()
Cable(a_terminations=[console_ports[0]], b_terminations=[console_server_ports[0]]).save()
Cable(a_terminations=[console_ports[1]], b_terminations=[console_server_ports[1]]).save()
# Third port is not connected
def test_name(self):
@@ -2095,8 +2107,8 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
ConsoleServerPort.objects.bulk_create(console_server_ports)
# Cables
Cable(termination_a=console_server_ports[0], termination_b=console_ports[0]).save()
Cable(termination_a=console_server_ports[1], termination_b=console_ports[1]).save()
Cable(a_terminations=[console_server_ports[0]], b_terminations=[console_ports[0]]).save()
Cable(a_terminations=[console_server_ports[1]], b_terminations=[console_ports[1]]).save()
# Third port is not connected
def test_name(self):
@@ -2242,8 +2254,8 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
PowerPort.objects.bulk_create(power_ports)
# Cables
Cable(termination_a=power_ports[0], termination_b=power_outlets[0]).save()
Cable(termination_a=power_ports[1], termination_b=power_outlets[1]).save()
Cable(a_terminations=[power_ports[0]], b_terminations=[power_outlets[0]]).save()
Cable(a_terminations=[power_ports[1]], b_terminations=[power_outlets[1]]).save()
# Third port is not connected
def test_name(self):
@@ -2397,8 +2409,8 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
PowerOutlet.objects.bulk_create(power_outlets)
# Cables
Cable(termination_a=power_outlets[0], termination_b=power_ports[0]).save()
Cable(termination_a=power_outlets[1], termination_b=power_ports[1]).save()
Cable(a_terminations=[power_outlets[0]], b_terminations=[power_ports[0]]).save()
Cable(a_terminations=[power_outlets[1]], b_terminations=[power_ports[1]]).save()
# Third port is not connected
def test_name(self):
@@ -2547,20 +2559,115 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis, vc_position=2, vc_priority=2)
interfaces = (
Interface(device=devices[0], module=modules[0], name='Interface 1', label='A', type=InterfaceTypeChoices.TYPE_1GE_SFP, enabled=True, mgmt_only=True, mtu=100, mode=InterfaceModeChoices.MODE_ACCESS, mac_address='00-00-00-00-00-01', description='First', vrf=vrfs[0], speed=1000000, duplex='half'),
Interface(device=devices[1], module=modules[1], name='Interface 2', label='B', type=InterfaceTypeChoices.TYPE_1GE_GBIC, enabled=True, mgmt_only=True, mtu=200, mode=InterfaceModeChoices.MODE_TAGGED, mac_address='00-00-00-00-00-02', description='Second', vrf=vrfs[1], speed=1000000, duplex='full'),
Interface(device=devices[2], module=modules[2], name='Interface 3', label='C', type=InterfaceTypeChoices.TYPE_1GE_FIXED, enabled=False, mgmt_only=False, mtu=300, mode=InterfaceModeChoices.MODE_TAGGED_ALL, mac_address='00-00-00-00-00-03', description='Third', vrf=vrfs[2], speed=100000, duplex='half'),
Interface(device=devices[3], name='Interface 4', label='D', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True, tx_power=40, speed=100000, duplex='full'),
Interface(device=devices[3], name='Interface 5', label='E', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True, tx_power=40),
Interface(device=devices[3], name='Interface 6', label='F', type=InterfaceTypeChoices.TYPE_OTHER, enabled=False, mgmt_only=False, tx_power=40),
Interface(device=devices[3], name='Interface 7', type=InterfaceTypeChoices.TYPE_80211AC, rf_role=WirelessRoleChoices.ROLE_AP, rf_channel=WirelessChannelChoices.CHANNEL_24G_1, rf_channel_frequency=2412, rf_channel_width=22),
Interface(device=devices[3], name='Interface 8', type=InterfaceTypeChoices.TYPE_80211AC, rf_role=WirelessRoleChoices.ROLE_STATION, rf_channel=WirelessChannelChoices.CHANNEL_5G_32, rf_channel_frequency=5160, rf_channel_width=20),
Interface(
device=devices[0],
module=modules[0],
name='Interface 1',
label='A',
type=InterfaceTypeChoices.TYPE_1GE_SFP,
enabled=True,
mgmt_only=True,
mtu=100,
mode=InterfaceModeChoices.MODE_ACCESS,
mac_address='00-00-00-00-00-01',
description='First',
vrf=vrfs[0],
speed=1000000,
duplex='half',
poe_mode=InterfacePoEModeChoices.MODE_PSE,
poe_type=InterfacePoETypeChoices.TYPE_1_8023AF
),
Interface(
device=devices[1],
module=modules[1],
name='Interface 2',
label='B',
type=InterfaceTypeChoices.TYPE_1GE_GBIC,
enabled=True,
mgmt_only=True,
mtu=200,
mode=InterfaceModeChoices.MODE_TAGGED,
mac_address='00-00-00-00-00-02',
description='Second',
vrf=vrfs[1],
speed=1000000,
duplex='full',
poe_mode=InterfacePoEModeChoices.MODE_PD,
poe_type=InterfacePoETypeChoices.TYPE_1_8023AF
),
Interface(
device=devices[2],
module=modules[2],
name='Interface 3',
label='C',
type=InterfaceTypeChoices.TYPE_1GE_FIXED,
enabled=False,
mgmt_only=False,
mtu=300,
mode=InterfaceModeChoices.MODE_TAGGED_ALL,
mac_address='00-00-00-00-00-03',
description='Third',
vrf=vrfs[2],
speed=100000,
duplex='half',
poe_mode=InterfacePoEModeChoices.MODE_PSE,
poe_type=InterfacePoETypeChoices.TYPE_2_8023AT
),
Interface(
device=devices[3],
name='Interface 4',
label='D',
type=InterfaceTypeChoices.TYPE_OTHER,
enabled=True,
mgmt_only=True,
tx_power=40,
speed=100000,
duplex='full',
poe_mode=InterfacePoEModeChoices.MODE_PD,
poe_type=InterfacePoETypeChoices.TYPE_2_8023AT
),
Interface(
device=devices[3],
name='Interface 5',
label='E',
type=InterfaceTypeChoices.TYPE_OTHER,
enabled=True,
mgmt_only=True,
tx_power=40
),
Interface(
device=devices[3],
name='Interface 6',
label='F',
type=InterfaceTypeChoices.TYPE_OTHER,
enabled=False,
mgmt_only=False,
tx_power=40
),
Interface(
device=devices[3],
name='Interface 7',
type=InterfaceTypeChoices.TYPE_80211AC,
rf_role=WirelessRoleChoices.ROLE_AP,
rf_channel=WirelessChannelChoices.CHANNEL_24G_1,
rf_channel_frequency=2412,
rf_channel_width=22
),
Interface(
device=devices[3],
name='Interface 8',
type=InterfaceTypeChoices.TYPE_80211AC,
rf_role=WirelessRoleChoices.ROLE_STATION,
rf_channel=WirelessChannelChoices.CHANNEL_5G_32,
rf_channel_frequency=5160,
rf_channel_width=20
),
)
Interface.objects.bulk_create(interfaces)
# Cables
Cable(termination_a=interfaces[0], termination_b=interfaces[3]).save()
Cable(termination_a=interfaces[1], termination_b=interfaces[4]).save()
Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[3]]).save()
Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[4]]).save()
# Third pair is not connected
def test_name(self):
@@ -2601,6 +2708,14 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'mgmt_only': 'false'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_poe_mode(self):
params = {'poe_mode': [InterfacePoEModeChoices.MODE_PD, InterfacePoEModeChoices.MODE_PSE]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_poe_type(self):
params = {'poe_type': [InterfacePoETypeChoices.TYPE_1_8023AF, InterfacePoETypeChoices.TYPE_2_8023AT]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_mode(self):
params = {'mode': InterfaceModeChoices.MODE_ACCESS}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@@ -2827,8 +2942,8 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
FrontPort.objects.bulk_create(front_ports)
# Cables
Cable(termination_a=front_ports[0], termination_b=front_ports[3]).save()
Cable(termination_a=front_ports[1], termination_b=front_ports[4]).save()
Cable(a_terminations=[front_ports[0]], b_terminations=[front_ports[3]]).save()
Cable(a_terminations=[front_ports[1]], b_terminations=[front_ports[4]]).save()
# Third port is not connected
def test_name(self):
@@ -2973,8 +3088,8 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
RearPort.objects.bulk_create(rear_ports)
# Cables
Cable(termination_a=rear_ports[0], termination_b=rear_ports[3]).save()
Cable(termination_a=rear_ports[1], termination_b=rear_ports[4]).save()
Cable(a_terminations=[rear_ports[0]], b_terminations=[rear_ports[3]]).save()
Cable(a_terminations=[rear_ports[1]], b_terminations=[rear_ports[4]]).save()
# Third port is not connected
def test_name(self):
@@ -3558,6 +3673,21 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
)
Site.objects.bulk_create(sites)
locations = (
Location(name='Location 1', site=sites[0], slug='location-1'),
Location(name='Location 2', site=sites[1], slug='location-1'),
Location(name='Location 3', site=sites[2], slug='location-1'),
)
for location in locations:
location.save()
racks = (
Rack(name='Rack 1', site=sites[0], location=locations[0]),
Rack(name='Rack 2', site=sites[1], location=locations[1]),
Rack(name='Rack 3', site=sites[2], location=locations[2]),
)
Rack.objects.bulk_create(racks)
tenants = (
Tenant(name='Tenant 1', slug='tenant-1'),
Tenant(name='Tenant 2', slug='tenant-2'),
@@ -3565,24 +3695,17 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
)
Tenant.objects.bulk_create(tenants)
racks = (
Rack(name='Rack 1', site=sites[0]),
Rack(name='Rack 2', site=sites[1]),
Rack(name='Rack 3', site=sites[2]),
)
Rack.objects.bulk_create(racks)
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], position=1),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], position=2),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[1], rack=racks[1], position=1),
Device(name='Device 4', device_type=device_type, device_role=device_role, site=sites[1], rack=racks[1], position=2),
Device(name='Device 5', device_type=device_type, device_role=device_role, site=sites[2], rack=racks[2], position=1),
Device(name='Device 6', device_type=device_type, device_role=device_role, site=sites[2], rack=racks[2], position=2),
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], location=locations[0], position=1),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], location=locations[0], position=2),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[1], rack=racks[1], location=locations[1], position=1),
Device(name='Device 4', device_type=device_type, device_role=device_role, site=sites[1], rack=racks[1], location=locations[1], position=2),
Device(name='Device 5', device_type=device_type, device_role=device_role, site=sites[2], rack=racks[2], location=locations[2], position=1),
Device(name='Device 6', device_type=device_type, device_role=device_role, site=sites[2], rack=racks[2], location=locations[2], position=2),
)
Device.objects.bulk_create(devices)
@@ -3606,13 +3729,13 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
console_server_port = ConsoleServerPort.objects.create(device=devices[0], name='Console Server Port 1')
# Cables
Cable(termination_a=interfaces[1], termination_b=interfaces[2], label='Cable 1', type=CableTypeChoices.TYPE_CAT3, tenant=tenants[0], status=LinkStatusChoices.STATUS_CONNECTED, color='aa1409', length=10, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
Cable(termination_a=interfaces[3], termination_b=interfaces[4], label='Cable 2', type=CableTypeChoices.TYPE_CAT3, tenant=tenants[0], status=LinkStatusChoices.STATUS_CONNECTED, color='aa1409', length=20, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
Cable(termination_a=interfaces[5], termination_b=interfaces[6], label='Cable 3', type=CableTypeChoices.TYPE_CAT5E, tenant=tenants[1], status=LinkStatusChoices.STATUS_CONNECTED, color='f44336', length=30, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
Cable(termination_a=interfaces[7], termination_b=interfaces[8], label='Cable 4', type=CableTypeChoices.TYPE_CAT5E, tenant=tenants[1], status=LinkStatusChoices.STATUS_PLANNED, color='f44336', length=40, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
Cable(termination_a=interfaces[9], termination_b=interfaces[10], label='Cable 5', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=LinkStatusChoices.STATUS_PLANNED, color='e91e63', length=10, length_unit=CableLengthUnitChoices.UNIT_METER).save()
Cable(termination_a=interfaces[11], termination_b=interfaces[0], label='Cable 6', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=LinkStatusChoices.STATUS_PLANNED, color='e91e63', length=20, length_unit=CableLengthUnitChoices.UNIT_METER).save()
Cable(termination_a=console_port, termination_b=console_server_port, label='Cable 7').save()
Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[2]], label='Cable 1', type=CableTypeChoices.TYPE_CAT3, tenant=tenants[0], status=LinkStatusChoices.STATUS_CONNECTED, color='aa1409', length=10, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
Cable(a_terminations=[interfaces[3]], b_terminations=[interfaces[4]], label='Cable 2', type=CableTypeChoices.TYPE_CAT3, tenant=tenants[0], status=LinkStatusChoices.STATUS_CONNECTED, color='aa1409', length=20, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
Cable(a_terminations=[interfaces[5]], b_terminations=[interfaces[6]], label='Cable 3', type=CableTypeChoices.TYPE_CAT5E, tenant=tenants[1], status=LinkStatusChoices.STATUS_CONNECTED, color='f44336', length=30, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
Cable(a_terminations=[interfaces[7]], b_terminations=[interfaces[8]], label='Cable 4', type=CableTypeChoices.TYPE_CAT5E, tenant=tenants[1], status=LinkStatusChoices.STATUS_PLANNED, color='f44336', length=40, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
Cable(a_terminations=[interfaces[9]], b_terminations=[interfaces[10]], label='Cable 5', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=LinkStatusChoices.STATUS_PLANNED, color='e91e63', length=10, length_unit=CableLengthUnitChoices.UNIT_METER).save()
Cable(a_terminations=[interfaces[11]], b_terminations=[interfaces[0]], label='Cable 6', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=LinkStatusChoices.STATUS_PLANNED, color='e91e63', length=20, length_unit=CableLengthUnitChoices.UNIT_METER).save()
Cable(a_terminations=[console_port], b_terminations=[console_server_port], label='Cable 7').save()
def test_label(self):
params = {'label': ['Cable 1', 'Cable 2']}
@@ -3654,6 +3777,13 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'rack': [racks[0].name, racks[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
def test_location(self):
locations = Location.objects.all()[:2]
params = {'location_id': [locations[0].pk, locations[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
params = {'location': [locations[0].name, locations[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
def test_site(self):
site = Site.objects.all()[:2]
params = {'site_id': [site[0].pk, site[1].pk]}
@@ -3675,7 +3805,10 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_termination_ids(self):
interface_ids = Cable.objects.values_list('termination_a_id', flat=True)[:3]
interface_ids = CableTermination.objects.filter(
cable__in=Cable.objects.all()[:3],
cable_end='A'
).values_list('termination_id', flat=True)
params = {
'termination_a_type': 'dcim.interface',
'termination_a_id': list(interface_ids),
@@ -3819,8 +3952,8 @@ class PowerFeedTestCase(TestCase, ChangeLoggedFilterSetTests):
PowerPort(device=device, name='Power Port 2'),
]
PowerPort.objects.bulk_create(power_ports)
Cable(termination_a=power_feeds[0], termination_b=power_ports[0]).save()
Cable(termination_a=power_feeds[1], termination_b=power_ports[1]).save()
Cable(a_terminations=[power_feeds[0]], b_terminations=[power_ports[0]]).save()
Cable(a_terminations=[power_feeds[1]], b_terminations=[power_ports[1]]).save()
def test_name(self):
params = {'name': ['Power Feed 1', 'Power Feed 2']}

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