Compare commits

..

216 Commits

Author SHA1 Message Date
Jeremy Stretch
b7129e1456 Merge pull request #7984 from netbox-community/develop
Release v3.0.12
2021-12-06 12:07:04 -05:00
jeremystretch
dc6decd404 Release v3.0.12 2021-12-06 11:54:50 -05:00
jeremystretch
40c6b172f7 Fixes #7981: Fix Markdown sanitization regex 2021-12-06 11:33:00 -05:00
thatmattlove
7cb9cedfe1 Fixes #7823: Properly handle return_url when Save & Continue button is present 2021-12-03 16:20:05 -07:00
jeremystretch
97f0414ff3 Changelog for #7751, #7885, #7892 2021-12-03 09:51:05 -05:00
Jeremy Stretch
d5f308d9c9 Merge pull request #7928 from kkthxbye-code/fix-7751
Fix #7751: LDAP: Only get API user from ldap when FIND_GROUP_PERMS is on
2021-12-03 09:48:58 -05:00
Jonathan Senecal
1377eda0ba Add support for L22-30P power port type (#7915)
* Add support for L22-30P power port type

Fixes #7892

* Add support for L22-30R power outlet type
2021-12-03 09:43:42 -05:00
Jeremy Stretch
70259b0d04 Merge pull request #7970 from rhyser9/7885_linkify_vlan_name
Fixes #7885: Linkify VLAN name in VLAN tables
2021-12-03 09:41:28 -05:00
Rhys Barrie
f1466d6da3 netbox-community/netbox#7885: Linkify VLAN name in VLAN tables 2021-12-02 12:27:30 -05:00
jeremystretch
83010e278c Add changelog for #7932, #7941 2021-12-01 09:18:31 -05:00
thatmattlove
dc3040550d Merge branch 'fast-filter' into develop 2021-11-30 10:10:38 -07:00
Daniel Sheppard
09f038f997 Merge pull request #7941 from bluikko/patch-9
Add multistandard ITA power outlet type
2021-11-30 09:37:53 -06:00
bluikko
bbdd3804c7 Add multistandard ITA power outlet type 2021-11-26 10:06:52 +07:00
kkthxbye
a0b9ac7bcc UI: Improve performance of the quick filter 2021-11-25 12:14:07 +01:00
kkthxbye
8bb0cba949 Fix #7751 - LDAP: Only get API user from ldap when FIND_GROUP_PERMS is enabled 2021-11-25 08:09:50 +01:00
jeremystretch
86ada33577 PRVB 2021-11-24 13:58:57 -05:00
Jeremy Stretch
869808b3f9 Merge pull request #7922 from netbox-community/develop
Release v3.0.11
2021-11-24 13:51:17 -05:00
jeremystretch
57ccbf44b8 Release v3.0.11 2021-11-24 13:25:57 -05:00
jeremystretch
416caa8f50 Hide code blocks when not needed 2021-11-24 13:17:59 -05:00
jeremystretch
1e42fecf66 Changelog for #7657 2021-11-24 09:15:30 -05:00
Jeremy Stretch
c9b00891ed Merge pull request #7861 from netbox-community/7657-threadsafe-changelog
Fixes #7657: Make request/webhook caching thread-safe
2021-11-24 09:06:48 -05:00
Jeremy Stretch
497eacbea3 Merge pull request #7898 from ypid/fix/7897
CEE 7/5 exists as power port and power outlet. It is only a power port.
2021-11-22 13:45:08 -05:00
jeremystretch
f90c591c78 Fixes #7890: Correct typo 2021-11-22 13:36:51 -05:00
Robin Schneider
175498940e Fixes #7897: CEE 7/5 is only a power outlet, no power port
Ref:

* https://en.wikipedia.org/wiki/CEE_7_standard_AC_plugs_and_sockets#CEE_7/5_socket_and_CEE_7/6_plug_(French;_Type_E)
* https://blog.packetsar.com/wp-content/uploads/Power_and_Cooling_Cheat_Sheet.pdf
2021-11-21 23:41:36 +01:00
Robin Schneider
eded00cbb3 chore: Always use "CEE 7" (with the space) consistently 2021-11-21 22:23:29 +01:00
jeremystretch
e8d6281007 Changelog for #7399 2021-11-18 10:02:23 -05:00
Jeremy Stretch
8299845615 Merge pull request #7676 from kkthxbye-code/develop
Fix #7399: LDAP excessive CPU usage when AUTH_LDAP_FIND_GROUP_PERMS is enabled
2021-11-18 09:58:34 -05:00
jeremystretch
9ae5865c2d Fixes #7865: REST API should support null values for console port speeds 2021-11-18 09:34:41 -05:00
jeremystretch
c2d0cfdfc0 Fixes #7864: power_port can be null when creating power outlets 2021-11-18 09:27:45 -05:00
Jeremy Stretch
5dd252731e Merge pull request #7863 from etcet/sudo-ln-s
Fixes #7862: Docs: Link housekeeping cron using sudo
2021-11-18 09:16:55 -05:00
Chris James
7b9436d2b9 Docs: Run ln with sudo 2021-11-17 20:33:42 -06:00
jeremystretch
6a369ac985 Closes #7531: Add Markdown support for strikethrough formatting 2021-11-17 16:50:23 -05:00
jeremystretch
23d90823a3 Fixes #7720: Fix initialization of custom script MultiObjectVar field with multiple values 2021-11-17 16:22:47 -05:00
jeremystretch
4bfb6b476c Fixes #7859: Fix styling of form widgets under cable connection views 2021-11-17 15:53:26 -05:00
jeremystretch
0d60099588 Move request object and webhook queue to TLS 2021-11-17 15:12:19 -05:00
jeremystretch
94069e76c9 Fixes #7857: Fix ordering IP addresses by assignment status 2021-11-17 08:51:17 -05:00
jeremystretch
df9d67b873 Fixes #7851: Add missing cluster name filter for virtual machines 2021-11-17 08:48:09 -05:00
jeremystretch
f32e694499 Fixes #7739: Fix exception when tracing cable across circuit with no far end termination 2021-11-15 12:41:57 -05:00
jeremystretch
e5900a3fe3 Correct changelog for #7729 2021-11-15 09:04:18 -05:00
jeremystretch
6e151b044d Changelog for #7229, #7424, #7542 2021-11-15 08:56:03 -05:00
Jeremy Stretch
516bea6a0a Merge pull request #7829 from rhyser9/7542_prefix_vlan_group_column
Fix #7542: Add VLAN Group column to IP Prefix table
2021-11-15 08:54:30 -05:00
Jeremy Stretch
496cabcc53 Merge pull request #7828 from rhyser9/7229_bug_add_vlans_link
Fix #7229: Fix context of VLAN table in VLAN Group view
2021-11-15 08:52:36 -05:00
Jeremy Stretch
d051db5083 Merge pull request #7827 from rhyser9/7424_virtualchassis_id_filter
Fix #7424: Add virtual_chassis_id filter for device components
2021-11-15 08:43:45 -05:00
Rhys Barrie
660fc23e15 netbox-community/netbox#7542: Add VLAN Group column to IP Prefix table 2021-11-13 23:29:26 -05:00
Rhys Barrie
a5a480133f netbox-community/netbox#7229: Fix context of VLAN table in VLAN Group view 2021-11-13 23:08:46 -05:00
Rhys Barrie
68b544c676 netbox-community/netbox#7424: add filterset test for virtual_chassis_id 2021-11-13 22:16:18 -05:00
Rhys Barrie
a8c958ece2 netbox-community/netbox#7424: fix test failure from adding virtual chassis filter field 2021-11-13 22:01:15 -05:00
Rhys Barrie
f77f7ca0ec netbox-community/netbox#7424:make device component device field filter from selected virtual chassis 2021-11-13 21:35:13 -05:00
Rhys Barrie
6b21c8453f netbox-community/netbox#7424: Add virtual_chassis field to device component filter form 2021-11-13 21:33:52 -05:00
Rhys Barrie
fa8a8abc98 netbox-community/netbox#7424: Add virtual_chassis and virtual_chassis_id filter to device components 2021-11-13 21:30:38 -05:00
Jeremy Stretch
98cc36c458 Merge pull request #7824 from netbox-community/2101-q-filters
Closes #2101: Ensure all relevant models have a general purpose search filter
2021-11-12 15:48:19 -05:00
jeremystretch
f3beabba69 Changelog for #2101 2021-11-12 15:33:49 -05:00
jeremystretch
467fa5a847 Add q filters for Token and ObjectPermission filter sets 2021-11-12 15:30:16 -05:00
jeremystretch
50f283cf28 Add q filter for extras models 2021-11-12 15:26:58 -05:00
jeremystretch
f49d7008a0 Add q filters for connection lists 2021-11-12 15:05:33 -05:00
jeremystretch
1fed564c47 Clean up script & report lists 2021-11-12 14:44:14 -05:00
jeremystretch
bb99c3e6f9 Changelog for #7803, #7810 2021-11-12 13:46:06 -05:00
Jeremy Stretch
8820cac792 Merge pull request #7820 from kkthxbye-code/script-reload
Fix #7803: Clear sys.modules cache when reloading scripts
2021-11-12 13:44:42 -05:00
Jeremy Stretch
ada911c20b Merge pull request #7816 from byts-tech/FR7810
Fixes: #7810 Add IEEE 802.15.1 Interface Type
2021-11-12 13:40:41 -05:00
jeremystretch
17e01644f5 Fixes #7813: Fix handling of errors during export template rendering 2021-11-12 13:32:52 -05:00
kkthxbye
9458521f3e Merge branch 'netbox-community:develop' into script-reload 2021-11-12 17:07:11 +01:00
Flo
8aa73c5900 Add IEEE 802.15.1 Interface Type 2021-11-12 16:05:42 +01:00
jeremystretch
c0ca1eaf90 PRVB 2021-11-12 08:54:08 -05:00
Jeremy Stretch
b29a5511df Merge pull request #7815 from netbox-community/develop
Release v3.0.10
2021-11-12 08:50:43 -05:00
jeremystretch
49e77841e0 Release v3.0.10 2021-11-12 08:36:33 -05:00
jeremystretch
daf6c8e327 Fixes #7814: Fix restriction of user & group objects in GraphQL API queries 2021-11-12 08:23:58 -05:00
jeremystretch
9f8068e8d1 Fixes #7808: Fix reference values for content type under custom field import form 2021-11-11 16:21:27 -05:00
jeremystretch
0b705553a5 Fixes #7809: Add missing export template support for various models 2021-11-11 16:16:54 -05:00
jeremystretch
a799094227 Fixes #7788: Improve XSS mitigation in Markdown renderer 2021-11-11 15:38:34 -05:00
jeremystretch
2f064cdfd1 Changelog for #7767 2021-11-11 12:30:28 -05:00
Jeremy Stretch
6c28182dd3 Merge pull request #7767 from CironAkono/FR6925
Fixes: #6925 Interfaces Table - bring back the visual aids from v2.9
2021-11-11 12:29:01 -05:00
jeremystretch
3cb8c5db28 Fixes #7654: Fix assignment of members to virtual chassis with initial position of zero 2021-11-11 12:10:47 -05:00
CironAkono
251abdb4dd Apply suggestions from code review
Co-authored-by: Jeremy Stretch <jstretch@ns1.com>
2021-11-11 16:36:13 +00:00
jeremystretch
726e4df54b Changelog for #7791 2021-11-11 11:31:51 -05:00
Jeremy Stretch
bd32a6ac8e Merge pull request #7804 from kkthxbye-code/fix-7791
Fix #7791 - Allow devicebay table to be sorted by status
2021-11-11 10:46:15 -05:00
jeremystretch
27d7400c36 Fixes #7802: Differentiate ID and VID columns in VLANs table 2021-11-11 10:23:38 -05:00
kkthxbye-code
53e52aeaa8 Fix sorting devicebay table by status 2021-11-11 14:05:39 +01:00
kkthxbye-code
ae6ed97a80 Clear sys.modules cache when reloading scripts 2021-11-11 11:53:31 +01:00
jeremystretch
3ad773beb3 Fixes #7741: Fix 404 when attaching multiple images in succession 2021-11-09 16:46:58 -05:00
jeremystretch
be91235858 Changelog for #7740 2021-11-09 16:08:11 -05:00
Jeremy Stretch
95fc0bbc94 Merge pull request #7774 from byts-tech/FR7740
Fixes: #7740 Add Mini-DIN 8 Console-Port-Type
2021-11-09 16:06:58 -05:00
jeremystretch
9dad7e4daf Fixes #7701: Fix conflation of assigned IP status & role in interface tables 2021-11-09 16:04:16 -05:00
jeremystretch
d08ed9fe5f Fixes #7780: Preserve mutli-line values during CSV file import 2021-11-09 15:24:21 -05:00
jeremystretch
82210cc116 Changelog for #7783 2021-11-09 15:15:34 -05:00
Jeremy Stretch
94d3e76517 Merge pull request #7785 from jasonyates/develop
Fixes #7783 - Site location visual changes
2021-11-09 15:12:47 -05:00
Jason Yates
3f72492a59 Fixed #7783 - Site location visual changes
Updating site location list to visually match the /dcim/locations list where child locations are "indtended" with mdi-circle-small.

Also removes the padding-left attribute on each row as it is no longer functional.
2021-11-09 15:18:46 +00:00
Flo
b7aa44837f Add Mini-DIN 8 Console-Port-Type 2021-11-08 17:50:13 +01:00
jeremystretch
7b7afd3e7b Changelog for #7765 2021-11-08 08:24:14 -05:00
Nico Domino
9c2514fce4 feat: add outer_width to RackTable (#7766)
* feat: add outer_width to RackTable

* fix: add outer_units to column display

* feat: add outer_depth to available columns
2021-11-08 08:15:26 -05:00
jeremystretch
e04402ed57 Allow bypassing the pre-commit script with NOVALIDATE=1 2021-11-05 13:40:38 -04:00
jeremystretch
3eda8d8482 Closes #7760: Add vid filter field to VLANs list 2021-11-05 13:31:36 -04:00
jeremystretch
79f2f03fb2 Issues policy tweaks 2021-11-05 13:26:18 -04:00
jeremystretch
e5d7578663 Fixes #7750: Fix cable trace image link 2021-11-05 11:10:17 -04:00
jeremystretch
773fd47ca6 Fixes #7752: Fix minimum version check under Python v3.10 2021-11-05 08:45:57 -04:00
kkthxbye-code
830cf4b31f Fix #7399 - LDAP using excessive CPU when AUTH_LDAP_FIND_GROUP_PERMS is enabled 2021-11-05 10:28:30 +01:00
jeremystretch
8f1acb700d Fix ID list creation in API tests 2021-11-04 11:31:39 -04:00
jeremystretch
7b1335825b Closes #7687: Update CentOS installation docs 2021-11-03 10:47:17 -04:00
jeremystretch
11e2200acf PRVB 2021-11-03 09:51:28 -04:00
Jeremy Stretch
f5356b84f6 Merge pull request #7730 from netbox-community/develop
Release v3.0.9
2021-11-03 09:47:37 -04:00
jeremystretch
1bf100ba15 Release v3.0.9 2021-11-03 09:32:57 -04:00
jeremystretch
7614f423e5 #7612: Use escape() rather than strip_tags() 2021-11-03 08:56:30 -04:00
jeremystretch
318c8b85e9 Fixes #7721: Retain pagination preference when MAX_PAGE_SIZE is zero 2021-11-03 08:25:50 -04:00
jeremystretch
7085fe77da Changelog for #6529 2021-11-03 08:11:38 -04:00
Jeremy Stretch
2e20d7f02b Merge pull request #7677 from netbox-community/6529-command-line-run-scripts
#6529 - Add CLI to run scripts
2021-11-03 08:10:36 -04:00
Steven
831065b5a1 #7717 Missing tags column definition in IP range table (#7724) 2021-11-03 08:05:34 -04:00
Daniel Sheppard
b97167e841 Fix PEP8 error 2021-11-02 21:40:40 -05:00
Daniel Sheppard
19bacc9e23 #6529 - Adjusted the arguments. Fixed documentation 2021-11-02 21:37:11 -05:00
jeremystretch
61b61b1bc0 Fixes #7664: Preserve initial form data when bulk edit validation fails 2021-11-02 17:07:30 -04:00
Daniel Sheppard
7c3318df92 #6529 - Adjusted the arguments. Added documentation 2021-11-02 15:56:42 -05:00
jeremystretch
d0b85586b9 Changelog & cleanup for #6930 2021-11-02 16:24:28 -04:00
Rhys Barrie
cef0d168a5 Closes #6930: Add 'ID' column to object tables (#7673)
* netbox-community/netbox#6930: Add ID column to devices, device types, and components

* netbox-community/netbox#6930: Add ID column to sites, racks, and tenants

* netbox-community/netbox#6930: Add ID column to power, providers, TODO circuits

* netbox-community/netbox#6930: Add ID column to virtualization tables

* netbox-community/netbox#6930: Add ID column to IPAM tables

* netbox-community/netbox#6930: Add ID column to 'extras' tables

* netbox-community/netbox#6930: Move ID column to BaseTable class

* netbox-community/netbox#6930: Don't linkify ID in device component template tables

* netbox-community/netbox#6930: Don't show ID column in interface/console/power connections tables

* netbox-community/netbox#6930: Don't show ID column in device component template tables

* netbox-community/netbox#6930: Add ID column to ObjectJournal, DeviceImport, and Circuit tables

* Exclude ID column from selected tables

* netbox-community/netbox#6930:revert default columns on ObjectChangeTable, not configurable

* netbox-community/netbox#6930: Add object ID to tagged objects table in tag detail view

Co-authored-by: Jeremy Stretch <jstretch@ns1.com>
2021-11-02 16:21:34 -04:00
jeremystretch
3a192223a3 Changelog & cleanup for #7668, #7717 2021-11-02 15:58:14 -04:00
Jeremy Stretch
288a1d23e5 Merge pull request #7693 from rhyser9/7668_location_detail_elevation
Closes #7668: Add 'View Elevations' button to location detail page
2021-11-02 15:53:09 -04:00
Jeremy Stretch
7c05db8e2f Merge pull request #7718 from cybarox/7717-fix-tags-missing-from-ip-ranges-table-fields
Closes #7717 Missing tags column in IP range table
2021-11-02 15:49:35 -04:00
Daniel Sheppard
b7c0e8b71f #6529 - Streamline code and resolve some issues 2021-11-02 13:12:12 -05:00
cybarox
a5ec0ee277 Closes #7717 Missing tags column in IP range table 2021-11-02 15:14:08 +01:00
Rhys Barrie
d528614cbf netbox-community/netbox#7668: Relocate elevations button to location details table 2021-11-01 11:17:15 -04:00
bluikko
b5e8157700 Fix #7685: Doc image links (#7698)
* Fix image link in custom-script.md

* Fix image link in cable.md

* Fix image link in power.md
2021-11-01 09:13:18 -04:00
Rhys Barrie
24d6941cc4 netbox-community/netbox#7668: Add 'View Elevations' button to location detail page 2021-10-29 14:07:02 -04:00
Daniel Sheppard
0a62f75a40 #6529 - Add CLI to run scripts 2021-10-28 15:14:42 -05:00
thatmattlove
a090955918 Fixes #7599: Improve color mode handling 2021-10-27 11:34:21 -07:00
jeremystretch
dfdeac4968 Fixes #7647: Require interface assignment when designating IP address as primary for device/VM during CSV import 2021-10-27 10:20:17 -04:00
jeremystretch
e84f2e3ad2 Fixes #7601: Correct devices count for locations within global search results 2021-10-27 10:10:14 -04:00
jeremystretch
98ca4f5b5a Fixes #7643: Fix circuit assignment when creating multiple terminations simultaneously 2021-10-27 10:02:36 -04:00
jeremystretch
87779b7b88 Fixes #7644: Prevent inadvertent deletion of prior change records when deleting objects (#7333 revisited) 2021-10-27 09:44:15 -04:00
jeremystretch
b56cae24c5 Fixes #7628: Fix load_yaml method for custom scripts 2021-10-27 09:04:18 -04:00
jeremystretch
d48a68317d Fixes #7612: Strip HTML from custom field descriptions 2021-10-27 08:41:28 -04:00
Miguel Teixeira
b07e88869a Fix interfaces row colors on device interfaces table 2021-10-24 03:31:29 +01:00
Miguel Teixeira
94bd27bcf5 Fix interface icons on the device interfaces table 2021-10-24 03:24:54 +01:00
jeremystretch
090df05193 PRVB 2021-10-20 09:59:33 -04:00
Jeremy Stretch
2c161c01c1 Merge pull request #7590 from netbox-community/develop
Release v3.0.8
2021-10-20 09:49:15 -04:00
jeremystretch
fc5a23cc88 Release v3.0.8 2021-10-20 09:31:12 -04:00
jeremystretch
73f2f9fc63 Closes #7551: Add UI field to filter interfaces by kind 2021-10-19 15:57:02 -04:00
jeremystretch
eb4b4a6c8d Closes #7561: Add a utilization column to the IP ranges table 2021-10-19 15:51:39 -04:00
jeremystretch
39430e01de Fixes #7550: Fix rendering of UTF8-encoded data in change records 2021-10-19 15:41:19 -04:00
jeremystretch
96015aa590 Fixes #7582: Fix rendering of CustomLink context data table 2021-10-19 15:31:07 -04:00
jeremystretch
c1720505f3 Fixes #7584: Fix alignment of object identifier under object view 2021-10-19 15:22:22 -04:00
Jeremy Stretch
5c338a90a1 Merge pull request #7566 from PieterL75/patch-1
Fix #7556 : NewVersion showing url
2021-10-19 15:20:58 -04:00
PieterL75
79cee12b1e Updated release notes with #7556 2021-10-19 16:23:05 +02:00
PieterL75
aa5c42683a Fix #7556 : NewVersion showing url 2021-10-18 16:12:23 +02:00
thatmattlove
9c6938e7ae Minor Style Improvement: Fix interface table dropdowns being hidden when opened 2021-10-15 17:45:47 -07:00
thatmattlove
811c21ec7e Minor Style Improvement: Add vertical spacing to Device Type component navigation & fix inconsistent component active color 2021-10-15 17:21:36 -07:00
thatmattlove
84c14aadc7 Fixes #7300: Fix incorrect Device LLDP interface row coloring & improve related JS 2021-10-15 17:07:54 -07:00
thatmattlove
f1f0d9cd0d Fixes #7495: Fix sidenav overlapping elements 2021-10-15 15:02:50 -07:00
jeremystretch
e16942dea5 Fixes #7529: Restore horizontal scrolling for tables in narrow viewports 2021-10-14 13:44:54 -04:00
Jeremy Stretch
12efcec3b0 Merge pull request #7546 from miaow2/7545-webhook-events-status
Fixes #7545: Incorrect display of Events status on webhook page
2021-10-14 13:43:00 -04:00
miaow2
a7b6c40596 Fixing display of webhook types 2021-10-14 20:35:21 +03:00
jeremystretch
b95773938d Fixes #7534: Avoid exception when utilizing "create and add another" twice in succession 2021-10-14 12:24:29 -04:00
jeremystretch
6898ae7106 Fixes #7544: Fix multi-value filtering of custom field objects 2021-10-14 11:36:13 -04:00
jeremystretch
1a4f8c5422 PRVB 2021-10-11 14:42:29 -04:00
Jeremy Stretch
66c4d23119 Merge pull request #7510 from netbox-community/develop
Release v3.0.7
2021-10-08 14:04:34 -04:00
jeremystretch
d66fc8f661 Release v3.0.7 2021-10-08 13:49:15 -04:00
jeremystretch
031876964f #2102: Implement q search filter for device type components 2021-10-08 13:42:43 -04:00
jeremystretch
c63766c4c6 Fix test for #7051 2021-10-07 14:19:29 -04:00
jeremystretch
af6237e12e Fixes #7479: Fix parent interface choices when bulk editing VM interfaces 2021-10-07 13:57:00 -04:00
jeremystretch
00328226ec Fixes #7051: Fix permissions evaluation and improve error handling for connected device REST API endpoint 2021-10-07 13:15:59 -04:00
jeremystretch
b31ba4e9d2 Changelog & UI tweaks for #6879 2021-10-07 12:41:24 -04:00
Jeremy Stretch
4be5d3f9e9 Merge pull request #6960 from candlerb/candlerb/6879-v3
Display device names in front of device front/rear images
2021-10-07 12:35:17 -04:00
jeremystretch
53154746fc Changelog for #7485 2021-10-07 10:40:51 -04:00
Jeremy Stretch
2f4c1b6e8f Merge pull request #7475 from HumanEquivalentUnit/patch-1
Mention data in custom fields, link Jinja2 docs.
2021-10-07 10:38:01 -04:00
Jeremy Stretch
045ec7d3a0 Merge pull request #7486 from alexanderhofstaetter/patch-1
Added "USB Micro AB" combo type to choices
2021-10-07 10:35:40 -04:00
jeremystretch
b73db750e5 Fixes #7471: Correct redirect URL when attaching images via "add another" button 2021-10-07 09:58:42 -04:00
jeremystretch
3f766ffea8 Fixes #7474: Fix AttributeError exception when rendering a report or custom script 2021-10-07 09:37:21 -04:00
Alexander Hofstätter
f28761202f Added "USB Micro AB" combo type to choices 2021-10-07 14:31:54 +02:00
HumanEquivalentUnit
6d1f07df05 Mention data in custom fields, link Jinja2 docs.
Resolves #7367
2021-10-07 00:51:07 +01:00
Brian Candler
eb9f2b36ab Display device names in front of device front/rear images
Fixes #6879
2021-10-06 18:07:28 +00:00
jeremystretch
2bd29127dc PRVB 2021-10-06 14:04:24 -04:00
Jeremy Stretch
3eef6363fd Merge pull request #7465 from netbox-community/develop
Release v3.0.6
2021-10-06 14:02:44 -04:00
jeremystretch
d451f30bfc Release v3.0.6 2021-10-06 13:45:02 -04:00
jeremystretch
105956f8e6 Closes #7464: Improve documentation for executing housekeeping task as a cron job 2021-10-06 13:24:13 -04:00
jeremystretch
39256afb67 Closes #7394: Enable filtering cables by termination type & ID in REST API 2021-10-06 12:06:32 -04:00
jeremystretch
69aaf28b9c Closes #6955: Include type, ID, and slug on object view 2021-10-06 11:23:06 -04:00
jeremystretch
b806220074 Closes #6850: Default to current user when creating journal entries via REST API 2021-10-06 10:56:50 -04:00
jeremystretch
d2bdf4e822 Closes #7462: Include count of assigned virtual machines under platform view 2021-10-06 10:12:44 -04:00
jeremystretch
3ab5682e7a Fixes #7460: Fix filtering connections by site ID 2021-10-06 10:08:56 -04:00
jeremystretch
c0010ec100 Fixes #7459: Pre-populate location data when adding a device to a rack 2021-10-06 10:00:31 -04:00
jeremystretch
6897c5fadd Fixes #7455: Fix site/provider network validation for circuit termination API serializer 2021-10-06 09:11:42 -04:00
jeremystretch
745aa23ed6 Fixes #7458: Correct tenants count label under tenant group view 2021-10-06 08:44:59 -04:00
jeremystretch
9089f5cf67 #7450: Clean up object edit forms 2021-10-05 15:37:49 -04:00
jeremystretch
dd79aae137 #7450: Misc UI cleanup 2021-10-05 15:21:49 -04:00
jeremystretch
26e470f521 #7449: Use lighter color for top-level nav menu items 2021-10-05 14:57:35 -04:00
jeremystretch
a34c8b80e5 #7449: Use original primary color 2021-10-05 14:52:10 -04:00
jeremystretch
854a12982f #7449: Lighten dropdown widget caret color 2021-10-05 14:36:33 -04:00
jeremystretch
cf173d4f50 #7449: Remove color from table header links 2021-10-05 14:16:19 -04:00
jeremystretch
7041486b93 #7449: Remove color from panel headers on home view 2021-10-05 14:03:14 -04:00
jeremystretch
548a8c3be3 #7449: Fix login banner color 2021-10-05 14:01:24 -04:00
jeremystretch
087a018faf Fix changelog for v3.0.5 2021-10-05 12:08:07 -04:00
jeremystretch
e09024e86f Fixes #7446: Fix exception when viewing a large number of child IPs within a prefix 2021-10-05 12:07:03 -04:00
jeremystretch
1757102536 Fixes #7442: Fix missing actions column on user-configured tables 2021-10-05 09:34:30 -04:00
jeremystretch
c262af550d PRVB 2021-10-04 14:18:42 -04:00
Jeremy Stretch
d9c6609b24 Merge pull request #7437 from netbox-community/develop
Release v3.0.5
2021-10-04 14:15:53 -04:00
jeremystretch
339bcb89bb Release v3.0.5 2021-10-04 13:46:34 -04:00
jeremystretch
b5884a5b54 Fixes #7215: Prevent rack elevations from overlapping when higher width is specified 2021-10-04 13:41:16 -04:00
thatmattlove
c818d63043 Fixes #7427: Don't select hidden rows when selecting all in a table 2021-10-04 09:19:18 -07:00
jeremystretch
c9c537a1b9 Fixes #6817: Custom field columns should be removed from tables upon their deletion 2021-10-01 20:22:54 -04:00
jeremystretch
1be748b479 Fixes #6433: Fix bulk editing of child prefixes under aggregate view 2021-10-01 16:21:16 -04:00
jeremystretch
376c776520 Fixes #7425: Housekeeping command should honor zero verbosity 2021-10-01 15:29:22 -04:00
jeremystretch
a1f271d7d9 Fixes #7417: Prevent exception when filtering objects list by invalid tag 2021-10-01 14:07:26 -04:00
jeremystretch
724997cb48 Closes #5925: Always show IP addresses tab under prefix view 2021-10-01 13:39:29 -04:00
jeremystretch
f3fe3f9a18 Closes #6708: Add image attachment support for circuits, power panels 2021-10-01 12:50:51 -04:00
jeremystretch
357a5d1e65 Refactor image attachments panel template 2021-10-01 12:45:41 -04:00
jeremystretch
460e3fd5d6 Introduce a common URL for the creation of image attachments 2021-10-01 12:34:30 -04:00
jeremystretch
257c0afdb5 Closes #6423: Cache rendered REST API specifications 2021-10-01 12:02:04 -04:00
jeremystretch
ed3bc7cdcc Changelog for #7387 2021-10-01 09:30:17 -04:00
Jeremy Stretch
bd181ac84f Merge pull request #7400 from maximumG/7387-order-scripts
Fixes #7387 : possibility to order scripts
2021-10-01 09:20:08 -04:00
jeremystretch
d1f5988db7 Closes #7319: Remove migrations check from upgrade.sh to avoid misleading error messages 2021-10-01 08:57:17 -04:00
jeremystretch
a5b99e7148 Fixes #7412: Fix exception in UI when adding child device to device bay 2021-09-30 12:29:08 -04:00
jeremystretch
114500e7f4 Fixes #7401: Pin jsonschema package to v3.2.0 to fix REST API docs rendering 2021-09-30 11:41:32 -04:00
jeremystretch
d9f178e315 Fixes #7411: Fix exception in UI when adding member devices to virtual chassis 2021-09-30 11:36:16 -04:00
maximumG
7337630704 chore: introduce the script_order notion in the documentation 2021-09-30 09:21:38 +02:00
maximumG
0fdd081869 feat: scripts within a module can now be ordered 2021-09-30 09:17:33 +02:00
jeremystretch
a9761e8dd2 Fixes #7397: Fix AttributeError exception when rendering export template for devices via REST API 2021-09-29 21:09:12 -04:00
jeremystretch
1f1a05dc67 Fixes #6895: Remove errant markup for null values in CSV export 2021-09-29 21:00:45 -04:00
thatmattlove
14b065cf5f Fixes #7373: Improve handling of mismatched server, client, and browser color-mode preferences 2021-09-29 17:44:28 -07:00
jeremystretch
47c3a20fda Correct version number referenced for installation video 2021-09-29 12:40:20 -04:00
jeremystretch
19c984bdab PRVB 2021-09-29 09:46:39 -04:00
160 changed files with 2241 additions and 1273 deletions

View File

@@ -13,11 +13,8 @@ body:
- type: input
attributes:
label: NetBox version
description: >
What version of NetBox are you currently running? (If you don't have access to the most
recent NetBox release, consider testing on our [demo instance](https://demo.netbox.dev/)
before opening a bug report to see if your issue has already been addressed.)
placeholder: v3.0.4
description: What version of NetBox are you currently running?
placeholder: v3.0.12
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.0.4
placeholder: v3.0.12
validations:
required: true
- type: dropdown

View File

@@ -76,14 +76,10 @@ free to add a comment with any additional justification for the feature.
(However, note that comments with no substance other than a "+1" will be
deleted. Please use GitHub's reactions feature to indicate your support.)
* Due to a large backlog of feature requests, we are not currently accepting
any proposals which substantially extend NetBox's functionality beyond its
current feature set. This includes the introduction of any new views or models
which have not already been proposed in an existing feature request.
* Before filing a new feature request, consider raising your idea on the
mailing list first. Feedback you receive there will help validate and shape the
proposed feature before filing a formal issue.
* Before filing a new feature request, consider raising your idea in a
[GitHub discussion](https://github.com/netbox-community/netbox/discussions)
first. Feedback you receive there will help validate and shape the proposed
feature before filing a formal issue.
* Good feature requests are very narrowly defined. Be sure to thoroughly
describe the functionality and data model(s) being proposed. The more effort

View File

@@ -5,6 +5,13 @@ NetBox includes a `housekeeping` management command that should be run nightly.
* Clearing expired authentication sessions from the database
* Deleting changelog records older than the configured [retention time](../configuration/optional-settings.md#changelog_retention)
This command can be invoked directly, or by using the shell script provided at `/opt/netbox/contrib/netbox-housekeeping.sh`. This script can be copied into your cron scheduler's daily jobs directory (e.g. `/etc/cron.daily`) or referenced directly within the cron configuration file.
This command can be invoked directly, or by using the shell script provided at `/opt/netbox/contrib/netbox-housekeeping.sh`. This script can be linked from your cron scheduler's daily jobs directory (e.g. `/etc/cron.daily`) or referenced directly within the cron configuration file.
The `housekeeping` command can also be run manually at any time: Running the command outside of scheduled execution times will not interfere with its operation.
```shell
sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
```
!!! note
On Debian-based systems, be sure to omit the `.sh` file extension when linking to the script from within a cron directory. Otherwise, the task may not run.
The `housekeeping` command can also be run manually at any time: Running the command outside scheduled execution times will not interfere with its operation.

View File

@@ -27,3 +27,13 @@ Device components represent discrete objects within a device which are used to t
---
{!models/dcim/cable.md!}
In the example below, three individual cables comprise a path between devices A and D:
![Cable path](../media/models/dcim_cable_trace.png)
Traced from Interface 1 on Device A, NetBox will show the following path:
* Cable 1: Interface 1 to Front Port 1
* Cable 2: Rear Port 1 to Rear Port 2
* Cable 3: Front Port 2 to Interface 2

View File

@@ -5,4 +5,4 @@
# Example Power Topology
![Power distribution model](/media/power_distribution.png)
![Power distribution model](../media/power_distribution.png)

View File

@@ -1 +0,0 @@
{!models/extras/customlink.md!}

View File

@@ -45,6 +45,20 @@ Defining script variables is optional: You may create a script with only a `run(
Any output generated by the script during its execution will be displayed under the "output" tab in the UI.
By default, scripts within a module are ordered alphabetically in the scripts list page. To return scripts in a specific order, you can define the `script_order` variable at the end of your module. The `script_order` variable is a tuple which contains each Script class in the desired order. Any scripts that are omitted from this list will be listed last.
```python
from extras.scripts import Script
class MyCustomScript(Script):
...
class AnotherCustomScript(Script):
...
script_order = (MyCustomScript, AnotherCustomScript)
```
## Module Attributes
### `name`
@@ -226,7 +240,7 @@ An IPv4 or IPv6 network with a mask. Returns a `netaddr.IPNetwork` object. Two a
!!! note
To run a custom script, a user must be assigned the `extras.run_script` permission. This is achieved by assigning the user (or group) a permission on the Script object and specifying the `run` action in the admin UI as shown below.
![Adding the run action to a permission](/media/admin_ui_run_permission.png)
![Adding the run action to a permission](../media/admin_ui_run_permission.png)
### Via the Web UI
@@ -245,6 +259,22 @@ http://netbox/api/extras/scripts/example.MyReport/ \
--data '{"data": {"foo": "somevalue", "bar": 123}, "commit": true}'
```
### Via the CLI
Scripts can be run on the CLI by invoking the management command:
```
python3 manage.py runscript [--commit] [--loglevel {debug,info,warning,error,critical}] [--data "<data>"] <module>.<script>
```
The required ``<module>.<script>`` argument is the script to run where ``<module>`` is the name of the python file in the ``scripts`` directory without the ``.py`` extension and ``<script>`` is the name of the script class in the ``<module>`` to run.
The optional ``--data "<data>"`` argument is the data to send to the script
The optional ``--loglevel`` argument is the desired logging level to output to the console.
The optional ``--commit`` argument will commit any changes in the script to the database.
## Example
Below is an example script that creates new objects for a planned site. The user is prompted for three variables:

View File

@@ -21,9 +21,6 @@ This section entails the installation and configuration of a local PostgreSQL da
sudo postgresql-setup --initdb
```
!!! info
PostgreSQL 9.6 and later are available natively on CentOS 8.2. If using an earlier CentOS release, you may need to [install it from an RPM](https://download.postgresql.org/pub/repos/yum/reporpms/EL-7-x86_64/).
CentOS configures ident host-based authentication for PostgreSQL by default. Because NetBox will need to authenticate using a username and password, modify `/var/lib/pgsql/data/pg_hba.conf` to support MD5 authentication by changing `ident` to `md5` for the lines below:
```no-highlight

View File

@@ -17,8 +17,13 @@ Begin by installing all system packages required by NetBox and its dependencies.
=== "CentOS"
!!! warning
CentOS 8 does not provide Python 3.7 or later via its native package manager. You will need to install it via some other means. [Here is an example](https://tecadmin.net/install-python-3-7-on-centos-8/) of installing Python 3.7 from source.
Once you have Python 3.7 or later installed, install the remaining system packages:
```no-highlight
sudo yum install -y gcc python36 python36-devel python3-pip libxml2-devel libxslt-devel libffi-devel libpq-devel openssl-devel redhat-rpm-config
sudo yum install -y gcc libxml2-devel libxslt-devel libffi-devel libpq-devel openssl-devel redhat-rpm-config
```
Before continuing with either platform, update pip (Python's package management tool) to its latest release:
@@ -259,10 +264,10 @@ python3 manage.py createsuperuser
NetBox includes a `housekeeping` management command that handles some recurring cleanup tasks, such as clearing out old sessions and expired change records. Although this command may be run manually, it is recommended to configure a scheduled job using the system's `cron` daemon or a similar utility.
A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be copied to your system's daily cron task directory, or included within the crontab directly. (If installing NetBox into a nonstandard path, be sure to update the system paths within this script first.)
A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be copied to or linked from your system's daily cron task directory, or included within the crontab directly. (If installing NetBox into a nonstandard path, be sure to update the system paths within this script first.)
```shell
cp /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/
sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
```
See the [housekeeping documentation](../administration/housekeeping.md) for further details.

View File

@@ -1,6 +1,6 @@
# Installation
The installation instructions provided here have been tested to work on Ubuntu 20.04 and CentOS 8.2. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
The installation instructions provided here have been tested to work on Ubuntu 20.04 and CentOS 8.3. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
The following sections detail how to set up a new instance of NetBox:
@@ -11,7 +11,7 @@ The following sections detail how to set up a new instance of NetBox:
5. [HTTP server](5-http-server.md)
6. [LDAP authentication](6-ldap.md) (optional)
The video below demonstrates the installation of NetBox v2.10.3 on Ubuntu 20.04 for your reference.
The video below demonstrates the installation of NetBox v3.0 on Ubuntu 20.04 for your reference.
<iframe width="560" height="315" src="https://www.youtube.com/embed/7Fpd2-q9_28" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>

View File

@@ -111,10 +111,10 @@ sudo systemctl restart netbox netbox-rq
## Verify Housekeeping Scheduling
If upgrading from a release prior to NetBox v3.0, check that a cron task (or similar scheduled process) has been configured to run NetBox's nightly housekeeping command. A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be copied to your system's daily cron task directory, or included within the crontab directly. (If NetBox has been installed in a nonstandard path, be sure to update the system paths within this script first.)
If upgrading from a release prior to NetBox v3.0, check that a cron task (or similar scheduled process) has been configured to run NetBox's nightly housekeeping command. A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be linked from your system's daily cron task directory, or included within the crontab directly. (If NetBox has been installed in a nonstandard path, be sure to update the system paths within this script first.)
```shell
cp /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/
sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
```
See the [housekeeping documentation](../administration/housekeeping.md) for further details.

View File

@@ -22,13 +22,3 @@ Each cable may be assigned a type, label, length, and color. Each cable is also
## Tracing Cables
A cable may be traced from either of its endpoints by clicking the "trace" button. (A REST API endpoint also provides this functionality.) NetBox will follow the path of connected cables from this termination across the directly connected cable to the far-end termination. If the cable connects to a pass-through port, and the peer port has another cable connected, NetBox will continue following the cable path until it encounters a non-pass-through or unconnected termination point. The entire path will be displayed to the user.
In the example below, three individual cables comprise a path between devices A and D:
![Cable path](/media/models/dcim_cable_trace.png)
Traced from Interface 1 on Device A, NetBox will show the following path:
* Cable 1: Interface 1 to Front Port 1
* Cable 2: Rear Port 1 to Rear Port 2
* Cable 3: Front Port 2 to Interface 2

View File

@@ -1,8 +1,8 @@
# Custom Links
Custom links allow users to display arbitrary hyperlinks to external content within NetBox object views. These are helpful for cross-referencing related records in systems outside NetBox. For example, you might create a custom link on the device view which links to the current device in a network monitoring system.
Custom links allow users to display arbitrary hyperlinks to external content within NetBox object views. These are helpful for cross-referencing related records in systems outside NetBox. For example, you might create a custom link on the device view which links to the current device in a Network Monitoring System (NMS).
Custom links are created by navigating to Customization > Custom Links. Each link is associated with a particular NetBox object type (site, device, prefix, etc.) and will be displayed on relevant views. Each link is assigned text and a URL, both of which support Jinja2 templating. The text and URL are rendered with the context variable `obj` representing the current object.
Custom links are created by navigating to Customization > Custom Links. Each link is associated with a particular NetBox object type (site, device, prefix, etc.) and will be displayed on relevant views. Each link has display text and a URL, and data from the Netbox item being viewed can be included in the link using [Jinja2 template code](https://jinja2docs.readthedocs.io/en/stable/) through the variable `obj`, and custom fields through `obj.cf`.
For example, you might define a link like this:

View File

@@ -1,5 +1,180 @@
# NetBox v3.0
## v3.0.12 (2021-12-06)
### Enhancements
* [#7751](https://github.com/netbox-community/netbox/issues/7751) - Get API user from LDAP only when `FIND_GROUP_PERMS` is enabled
* [#7885](https://github.com/netbox-community/netbox/issues/7885) - Linkify VLAN name in VLANs table
* [#7892](https://github.com/netbox-community/netbox/issues/7892) - Add L22-30 power port & outlet types
* [#7932](https://github.com/netbox-community/netbox/issues/7932) - Improve performance of the "quick find" function
* [#7941](https://github.com/netbox-community/netbox/issues/7941) - Add multi-standard ITA power outlet type
### Bug Fixes
* [#7823](https://github.com/netbox-community/netbox/issues/7823) - Fix issue where `return_url` is not honored when 'Save & Continue' button is present
* [#7981](https://github.com/netbox-community/netbox/issues/7981) - Fix Markdown sanitization regex
---
## v3.0.11 (2021-11-24)
### Enhancements
* [#2101](https://github.com/netbox-community/netbox/issues/2101) - Add missing `q` filters for necessary models
* [#7424](https://github.com/netbox-community/netbox/issues/7424) - Add virtual chassis filters for device components
* [#7531](https://github.com/netbox-community/netbox/issues/7531) - Add Markdown support for strikethrough formatting
* [#7542](https://github.com/netbox-community/netbox/issues/7542) - Add optional VLAN group column to prefixes table
* [#7803](https://github.com/netbox-community/netbox/issues/7803) - Improve live reloading of custom scripts
* [#7810](https://github.com/netbox-community/netbox/issues/7810) - Add IEEE 802.15.1 interface type
### Bug Fixes
* [#7399](https://github.com/netbox-community/netbox/issues/7399) - Fix excessive CPU utilization when `AUTH_LDAP_FIND_GROUP_PERMS` is enabled
* [#7657](https://github.com/netbox-community/netbox/issues/7657) - Make change logging middleware thread-safe
* [#7720](https://github.com/netbox-community/netbox/issues/7720) - Fix initialization of custom script MultiObjectVar field with multiple values
* [#7729](https://github.com/netbox-community/netbox/issues/7729) - Fix permissions evaluation when displaying VLAN group VLANs table
* [#7739](https://github.com/netbox-community/netbox/issues/7739) - Fix exception when tracing cable across circuit with no far end termination
* [#7813](https://github.com/netbox-community/netbox/issues/7813) - Fix handling of errors during export template rendering
* [#7851](https://github.com/netbox-community/netbox/issues/7851) - Add missing cluster name filter for virtual machines
* [#7857](https://github.com/netbox-community/netbox/issues/7857) - Fix ordering IP addresses by assignment status
* [#7859](https://github.com/netbox-community/netbox/issues/7859) - Fix styling of form widgets under cable connection views
* [#7864](https://github.com/netbox-community/netbox/issues/7864) - `power_port` can be null when creating power outlets via REST API
* [#7865](https://github.com/netbox-community/netbox/issues/7865) - REST API should support null values for console port speeds
---
## v3.0.10 (2021-11-12)
### Enhancements
* [#7740](https://github.com/netbox-community/netbox/issues/7740) - Add mini-DIN 8 console port type
* [#7760](https://github.com/netbox-community/netbox/issues/7760) - Add `vid` filter field to VLANs list
* [#7767](https://github.com/netbox-community/netbox/issues/7767) - Add visual aids to interfaces table for type, enabled status
### Bug Fixes
* [#7564](https://github.com/netbox-community/netbox/issues/7564) - Fix assignment of members to virtual chassis with initial position of zero
* [#7701](https://github.com/netbox-community/netbox/issues/7701) - Fix conflation of assigned IP status & role in interface tables
* [#7741](https://github.com/netbox-community/netbox/issues/7741) - Fix 404 when attaching multiple images in succession
* [#7752](https://github.com/netbox-community/netbox/issues/7752) - Fix minimum version check under Python v3.10
* [#7766](https://github.com/netbox-community/netbox/issues/7766) - Add missing outer dimension columns to rack table
* [#7780](https://github.com/netbox-community/netbox/issues/7780) - Preserve multi-line values during CSV file import
* [#7783](https://github.com/netbox-community/netbox/issues/7783) - Fix indentation of locations under site view
* [#7788](https://github.com/netbox-community/netbox/issues/7788) - Improve XSS mitigation in Markdown renderer
* [#7791](https://github.com/netbox-community/netbox/issues/7791) - Enable sorting device bays table by installed device status
* [#7802](https://github.com/netbox-community/netbox/issues/7802) - Differentiate ID and VID columns in VLANs table
* [#7808](https://github.com/netbox-community/netbox/issues/7808) - Fix reference values for content type under custom field import form
* [#7809](https://github.com/netbox-community/netbox/issues/7809) - Add missing export template support for various models
* [#7814](https://github.com/netbox-community/netbox/issues/7814) - Fix restriction of user & group objects in GraphQL API queries
---
## v3.0.9 (2021-11-03)
### Enhancements
* [#6529](https://github.com/netbox-community/netbox/issues/6529) - Introduce the `runscript` management command
* [#6930](https://github.com/netbox-community/netbox/issues/6930) - Add an optional "ID" column to all tables
* [#7668](https://github.com/netbox-community/netbox/issues/7668) - Add "view elevations" button to location view
### Bug Fixes
* [#7599](https://github.com/netbox-community/netbox/issues/7599) - Improve color mode preference handling
* [#7601](https://github.com/netbox-community/netbox/issues/7601) - Correct devices count for locations within global search results
* [#7612](https://github.com/netbox-community/netbox/issues/7612) - Strip HTML from custom field descriptions
* [#7628](https://github.com/netbox-community/netbox/issues/7628) - Fix `load_yaml` method for custom scripts
* [#7643](https://github.com/netbox-community/netbox/issues/7643) - Fix circuit assignment when creating multiple terminations simultaneously
* [#7644](https://github.com/netbox-community/netbox/issues/7644) - Prevent inadvertent deletion of prior change records when deleting objects (#7333 revisited)
* [#7647](https://github.com/netbox-community/netbox/issues/7647) - Require interface assignment when designating IP address as primary for device/VM during CSV import
* [#7664](https://github.com/netbox-community/netbox/issues/7664) - Preserve initial form data when bulk edit validation fails
* [#7717](https://github.com/netbox-community/netbox/issues/7717) - Restore missing tags column on IP range table
* [#7721](https://github.com/netbox-community/netbox/issues/7721) - Retain pagination preference when `MAX_PAGE_SIZE` is zero
---
## v3.0.8 (2021-10-20)
### Enhancements
* [#7551](https://github.com/netbox-community/netbox/issues/7551) - Add UI field to filter interfaces by kind
* [#7561](https://github.com/netbox-community/netbox/issues/7561) - Add a utilization column to the IP ranges table
### Bug Fixes
* [#7300](https://github.com/netbox-community/netbox/issues/7300) - Fix incorrect Device LLDP interface row coloring
* [#7495](https://github.com/netbox-community/netbox/issues/7495) - Fix navigation UI issue that caused improper element overlap
* [#7529](https://github.com/netbox-community/netbox/issues/7529) - Restore horizontal scrolling for tables in narrow viewports
* [#7534](https://github.com/netbox-community/netbox/issues/7534) - Avoid exception when utilizing "create and add another" twice in succession
* [#7544](https://github.com/netbox-community/netbox/issues/7544) - Fix multi-value filtering of custom field objects
* [#7545](https://github.com/netbox-community/netbox/issues/7545) - Fix incorrect display of update/delete events for webhooks
* [#7550](https://github.com/netbox-community/netbox/issues/7550) - Fix rendering of UTF8-encoded data in change records
* [#7556](https://github.com/netbox-community/netbox/issues/7556) - Fix display of version when new release is available
* [#7584](https://github.com/netbox-community/netbox/issues/7584) - Fix alignment of object identifier under object view
---
## v3.0.7 (2021-10-08)
### Enhancements
* [#6879](https://github.com/netbox-community/netbox/issues/6879) - Improve ability to toggle images/labels in rack elevations
* [#7485](https://github.com/netbox-community/netbox/issues/7485) - Add USB micro AB type
### Bug Fixes
* [#7051](https://github.com/netbox-community/netbox/issues/7051) - Fix permissions evaluation and improve error handling for connected device REST API endpoint
* [#7471](https://github.com/netbox-community/netbox/issues/7471) - Correct redirect URL when attaching images via "add another" button
* [#7474](https://github.com/netbox-community/netbox/issues/7474) - Fix AttributeError exception when rendering a report or custom script
* [#7479](https://github.com/netbox-community/netbox/issues/7479) - Fix parent interface choices when bulk editing VM interfaces
---
## v3.0.6 (2021-10-06)
### Enhancements
* [#6850](https://github.com/netbox-community/netbox/issues/6850) - Default to current user when creating journal entries via REST API
* [#6955](https://github.com/netbox-community/netbox/issues/6955) - Include type, ID, and slug on object view
* [#7394](https://github.com/netbox-community/netbox/issues/7394) - Enable filtering cables by termination type & ID in REST API
* [#7462](https://github.com/netbox-community/netbox/issues/7462) - Include count of assigned virtual machines under platform view
### Bug Fixes
* [#7442](https://github.com/netbox-community/netbox/issues/7442) - Fix missing actions column on user-configured tables
* [#7446](https://github.com/netbox-community/netbox/issues/7446) - Fix exception when viewing a large number of child IPs within a prefix
* [#7455](https://github.com/netbox-community/netbox/issues/7455) - Fix site/provider network validation for circuit termination API serializer
* [#7459](https://github.com/netbox-community/netbox/issues/7459) - Pre-populate location data when adding a device to a rack
* [#7460](https://github.com/netbox-community/netbox/issues/7460) - Fix filtering connections by site ID
---
## v3.0.5 (2021-10-04)
### Enhancements
* [#5925](https://github.com/netbox-community/netbox/issues/5925) - Always show IP addresses tab under prefix view
* [#6423](https://github.com/netbox-community/netbox/issues/6423) - Cache rendered REST API specifications
* [#6708](https://github.com/netbox-community/netbox/issues/6708) - Add image attachment support for circuits, power panels
* [#7387](https://github.com/netbox-community/netbox/issues/7387) - Enable arbitrary ordering of custom scripts
### Bug Fixes
* [#6433](https://github.com/netbox-community/netbox/issues/6433) - Fix bulk editing of child prefixes under aggregate view
* [#6817](https://github.com/netbox-community/netbox/issues/6817) - Custom field columns should be removed from tables upon their deletion
* [#6895](https://github.com/netbox-community/netbox/issues/6895) - Remove errant markup for null values in CSV export
* [#7215](https://github.com/netbox-community/netbox/issues/7215) - Prevent rack elevations from overlapping when higher width is specified
* [#7373](https://github.com/netbox-community/netbox/issues/7373) - Fix flashing when server, client, and browser color-mode preferences are mismatched
* [#7397](https://github.com/netbox-community/netbox/issues/7397) - Fix AttributeError exception when rendering export template for devices via REST API
* [#7401](https://github.com/netbox-community/netbox/issues/7401) - Pin `jsonschema` package to v3.2.0 to fix REST API docs rendering
* [#7411](https://github.com/netbox-community/netbox/issues/7411) - Fix exception in UI when adding member devices to virtual chassis
* [#7412](https://github.com/netbox-community/netbox/issues/7412) - Fix exception in UI when adding child device to device bay
* [#7417](https://github.com/netbox-community/netbox/issues/7417) - Prevent exception when filtering objects list by invalid tag
* [#7425](https://github.com/netbox-community/netbox/issues/7425) - Housekeeping command should honor zero verbosity
* [#7427](https://github.com/netbox-community/netbox/issues/7427) - Don't select hidden rows when selecting all in a table
---
## v3.0.4 (2021-09-29)
### Enhancements
@@ -30,6 +205,8 @@
* [#7374](https://github.com/netbox-community/netbox/issues/7374) - Add missing `face` parameter to API elevations request when editing device
* [#7392](https://github.com/netbox-community/netbox/issues/7392) - Fix "help" links for custom fields, other models
---
## v3.0.3 (2021-09-20)
### Enhancements
@@ -289,7 +466,7 @@ Note that NetBox's `rqworker` process will _not_ service custom queues by defaul
* [#6154](https://github.com/netbox-community/netbox/issues/6154) - Allow decimal values for cable lengths
* [#6328](https://github.com/netbox-community/netbox/issues/6328) - Build and serve documentation locally
### Bug Fixes (from v3.2-beta2)
### Bug Fixes (from v3.0-beta2)
* [#6977](https://github.com/netbox-community/netbox/issues/6977) - Truncate global search dropdown on small screens
* [#6979](https://github.com/netbox-community/netbox/issues/6979) - Hide "create & add another" button for circuit terminations

View File

@@ -65,7 +65,7 @@ nav:
- Customization:
- Custom Fields: 'customization/custom-fields.md'
- Custom Validation: 'customization/custom-validation.md'
- Custom Links: 'customization/custom-links.md'
- Custom Links: 'models/extras/customlink.md'
- Export Templates: 'customization/export-templates.md'
- Custom Scripts: 'customization/custom-scripts.md'
- Reports: 'customization/reports.md'

View File

@@ -3,10 +3,10 @@ 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 CableTerminationSerializer, ConnectedEndpointSerializer
from dcim.api.serializers import CableTerminationSerializer
from netbox.api import ChoiceField
from netbox.api.serializers import (
BaseModelSerializer, OrganizationalModelSerializer, PrimaryModelSerializer, WritableNestedSerializer
OrganizationalModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer
)
from tenancy.api.nested_serializers import NestedTenantSerializer
from .nested_serializers import *
@@ -90,11 +90,11 @@ class CircuitSerializer(PrimaryModelSerializer):
]
class CircuitTerminationSerializer(BaseModelSerializer, CableTerminationSerializer):
class CircuitTerminationSerializer(ValidatedModelSerializer, CableTerminationSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
circuit = NestedCircuitSerializer()
site = NestedSiteSerializer(required=False)
provider_network = NestedProviderNetworkSerializer(required=False)
site = NestedSiteSerializer(required=False, allow_null=True)
provider_network = NestedProviderNetworkSerializer(required=False, allow_null=True)
cable = NestedCableSerializer(read_only=True)
class Meta:

View File

@@ -1,3 +1,4 @@
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
@@ -202,6 +203,9 @@ class Circuit(PrimaryModel):
comments = models.TextField(
blank=True
)
images = GenericRelation(
to='extras.ImageAttachment'
)
# Cache associated CircuitTerminations
termination_a = models.ForeignKey(

View File

@@ -11,6 +11,7 @@ def update_circuit(instance, **kwargs):
When a CircuitTermination has been modified, update its parent Circuit.
"""
termination_name = f'termination_{instance.term_side.lower()}'
instance.circuit.refresh_from_db()
setattr(instance.circuit, termination_name, instance)
instance.circuit.save()

View File

@@ -44,8 +44,8 @@ class ProviderTable(BaseTable):
class Meta(BaseTable.Meta):
model = Provider
fields = (
'pk', 'name', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'circuit_count', 'comments',
'tags',
'pk', 'id', 'name', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'circuit_count',
'comments', 'tags',
)
default_columns = ('pk', 'name', 'asn', 'account', 'circuit_count')
@@ -69,7 +69,7 @@ class ProviderNetworkTable(BaseTable):
class Meta(BaseTable.Meta):
model = ProviderNetwork
fields = ('pk', 'name', 'provider', 'description', 'comments', 'tags')
fields = ('pk', 'id', 'name', 'provider', 'description', 'comments', 'tags')
default_columns = ('pk', 'name', 'provider', 'description')
@@ -89,7 +89,7 @@ class CircuitTypeTable(BaseTable):
class Meta(BaseTable.Meta):
model = CircuitType
fields = ('pk', 'name', 'circuit_count', 'description', 'slug', 'actions')
fields = ('pk', 'id', 'name', 'circuit_count', 'description', 'slug', 'actions')
default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug', 'actions')
@@ -101,7 +101,7 @@ class CircuitTable(BaseTable):
pk = ToggleColumn()
cid = tables.Column(
linkify=True,
verbose_name='ID'
verbose_name='Circuit ID'
)
provider = tables.Column(
linkify=True
@@ -124,7 +124,7 @@ class CircuitTable(BaseTable):
class Meta(BaseTable.Meta):
model = Circuit
fields = (
'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'install_date',
'pk', 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'install_date',
'commit_rate', 'description', 'comments', 'tags',
)
default_columns = (

View File

@@ -136,14 +136,20 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
SIDE_A = CircuitTerminationSideChoices.SIDE_A
SIDE_Z = CircuitTerminationSideChoices.SIDE_Z
provider = Provider.objects.create(name='Provider 1', slug='provider-1')
circuit_type = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
)
Site.objects.bulk_create(sites)
provider = Provider.objects.create(name='Provider 1', slug='provider-1')
circuit_type = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
provider_networks = (
ProviderNetwork(provider=provider, name='Provider Network 1'),
ProviderNetwork(provider=provider, name='Provider Network 2'),
)
ProviderNetwork.objects.bulk_create(provider_networks)
circuits = (
Circuit(cid='Circuit 1', provider=provider, type=circuit_type),
@@ -153,10 +159,10 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
Circuit.objects.bulk_create(circuits)
circuit_terminations = (
CircuitTermination(circuit=circuits[0], site=sites[0], term_side=SIDE_A),
CircuitTermination(circuit=circuits[0], site=sites[1], term_side=SIDE_Z),
CircuitTermination(circuit=circuits[1], site=sites[0], term_side=SIDE_A),
CircuitTermination(circuit=circuits[1], site=sites[1], term_side=SIDE_Z),
CircuitTermination(circuit=circuits[0], term_side=SIDE_A, site=sites[0]),
CircuitTermination(circuit=circuits[0], term_side=SIDE_Z, provider_network=provider_networks[0]),
CircuitTermination(circuit=circuits[1], term_side=SIDE_A, site=sites[1]),
CircuitTermination(circuit=circuits[1], term_side=SIDE_Z, provider_network=provider_networks[1]),
)
CircuitTermination.objects.bulk_create(circuit_terminations)
@@ -164,13 +170,13 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
{
'circuit': circuits[2].pk,
'term_side': SIDE_A,
'site': sites[1].pk,
'site': sites[0].pk,
'port_speed': 200000,
},
{
'circuit': circuits[2].pk,
'term_side': SIDE_Z,
'site': sites[1].pk,
'provider_network': provider_networks[0].pk,
'port_speed': 200000,
},
]

View File

@@ -340,7 +340,7 @@ class NestedVirtualChassisSerializer(WritableNestedSerializer):
class Meta:
model = models.VirtualChassis
fields = ['id', 'name', 'url', 'master', 'member_count']
fields = ['id', 'url', 'display', 'name', 'master', 'member_count']
#

View File

@@ -356,7 +356,8 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
required=False
)
power_port = NestedPowerPortTemplateSerializer(
required=False
required=False,
allow_null=True
)
feed_leg = ChoiceField(
choices=PowerOutletFeedLegChoices,
@@ -538,7 +539,7 @@ class ConsoleServerPortSerializer(PrimaryModelSerializer, CableTerminationSerial
)
speed = ChoiceField(
choices=ConsolePortSpeedChoices,
allow_blank=True,
allow_null=True,
required=False
)
cable = NestedCableSerializer(read_only=True)
@@ -562,7 +563,7 @@ class ConsolePortSerializer(PrimaryModelSerializer, CableTerminationSerializer,
)
speed = ChoiceField(
choices=ConsolePortSpeedChoices,
allow_blank=True,
allow_null=True,
required=False
)
cable = NestedCableSerializer(read_only=True)
@@ -585,7 +586,8 @@ class PowerOutletSerializer(PrimaryModelSerializer, CableTerminationSerializer,
required=False
)
power_port = NestedPowerPortSerializer(
required=False
required=False,
allow_null=True
)
feed_leg = ChoiceField(
choices=PowerOutletFeedLegChoices,

View File

@@ -2,7 +2,7 @@ import socket
from collections import OrderedDict
from django.conf import settings
from django.http import HttpResponseForbidden, HttpResponse
from django.http import Http404, HttpResponse, HttpResponseForbidden
from django.shortcuts import get_object_or_404
from drf_yasg import openapi
from drf_yasg.openapi import Parameter
@@ -17,10 +17,10 @@ from dcim import filtersets
from dcim.models import *
from extras.api.views import ConfigContextQuerySetMixin, CustomFieldModelViewSet
from ipam.models import Prefix, VLAN
from netbox.api.views import ModelViewSet
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
from netbox.api.exceptions import ServiceUnavailable
from netbox.api.metadata import ContentTypeMetadata
from netbox.api.views import ModelViewSet
from utilities.api import get_serializer_for_model
from utilities.utils import count_related, decode_dict
from virtualization.models import VirtualMachine
@@ -675,15 +675,25 @@ class ConnectedDeviceViewSet(ViewSet):
if not peer_device_name or not peer_interface_name:
raise MissingFilterException(detail='Request must include "peer_device" and "peer_interface" filters.')
# Determine local interface from peer interface's connection
# Determine local endpoint from peer interface's connection
peer_device = get_object_or_404(
Device.objects.restrict(request.user, 'view'),
name=peer_device_name
)
peer_interface = get_object_or_404(
Interface.objects.all(),
device__name=peer_device_name,
Interface.objects.restrict(request.user, 'view'),
device=peer_device,
name=peer_interface_name
)
local_interface = peer_interface.connected_endpoint
endpoint = peer_interface.connected_endpoint
if local_interface is None:
return Response()
# If an Interface, return the parent device
if type(endpoint) is Interface:
device = get_object_or_404(
Device.objects.restrict(request.user, 'view'),
pk=endpoint.device_id
)
return Response(serializers.DeviceSerializer(device, context={'request': request}).data)
return Response(serializers.DeviceSerializer(local_interface.device, context={'request': request}).data)
# Connected endpoint is none or not an Interface
raise Http404

View File

@@ -185,6 +185,7 @@ class ConsolePortTypeChoices(ChoiceSet):
TYPE_RJ11 = 'rj-11'
TYPE_RJ12 = 'rj-12'
TYPE_RJ45 = 'rj-45'
TYPE_MINI_DIN_8 = 'mini-din-8'
TYPE_USB_A = 'usb-a'
TYPE_USB_B = 'usb-b'
TYPE_USB_C = 'usb-c'
@@ -192,6 +193,7 @@ class ConsolePortTypeChoices(ChoiceSet):
TYPE_USB_MINI_B = 'usb-mini-b'
TYPE_USB_MICRO_A = 'usb-micro-a'
TYPE_USB_MICRO_B = 'usb-micro-b'
TYPE_USB_MICRO_AB = 'usb-micro-ab'
TYPE_OTHER = 'other'
CHOICES = (
@@ -201,6 +203,7 @@ class ConsolePortTypeChoices(ChoiceSet):
(TYPE_RJ11, 'RJ-11'),
(TYPE_RJ12, 'RJ-12'),
(TYPE_RJ45, 'RJ-45'),
(TYPE_MINI_DIN_8, 'Mini-DIN 8'),
)),
('USB', (
(TYPE_USB_A, 'USB Type A'),
@@ -210,6 +213,7 @@ class ConsolePortTypeChoices(ChoiceSet):
(TYPE_USB_MINI_B, 'USB Mini B'),
(TYPE_USB_MICRO_A, 'USB Micro A'),
(TYPE_USB_MICRO_B, 'USB Micro B'),
(TYPE_USB_MICRO_AB, 'USB Micro AB'),
)),
('Other', (
(TYPE_OTHER, 'Other'),
@@ -308,6 +312,7 @@ class PowerPortTypeChoices(ChoiceSet):
TYPE_NEMA_L1560P = 'nema-l15-60p'
TYPE_NEMA_L2120P = 'nema-l21-20p'
TYPE_NEMA_L2130P = 'nema-l21-30p'
TYPE_NEMA_L2230P = 'nema-l22-30p'
# California style
TYPE_CS6361C = 'cs6361c'
TYPE_CS6365C = 'cs6365c'
@@ -337,6 +342,7 @@ class PowerPortTypeChoices(ChoiceSet):
TYPE_USB_MINI_B = 'usb-mini-b'
TYPE_USB_MICRO_A = 'usb-micro-a'
TYPE_USB_MICRO_B = 'usb-micro-b'
TYPE_USB_MICRO_AB = 'usb-micro-ab'
TYPE_USB_3_B = 'usb-3-b'
TYPE_USB_3_MICROB = 'usb-3-micro-b'
# Direct current (DC)
@@ -412,6 +418,7 @@ class PowerPortTypeChoices(ChoiceSet):
(TYPE_NEMA_L1560P, 'NEMA L15-60P'),
(TYPE_NEMA_L2120P, 'NEMA L21-20P'),
(TYPE_NEMA_L2130P, 'NEMA L21-30P'),
(TYPE_NEMA_L2230P, 'NEMA L22-30P'),
)),
('California Style', (
(TYPE_CS6361C, 'CS6361C'),
@@ -423,7 +430,7 @@ class PowerPortTypeChoices(ChoiceSet):
)),
('International/ITA', (
(TYPE_ITA_C, 'ITA Type C (CEE 7/16)'),
(TYPE_ITA_E, 'ITA Type E (CEE 7/5)'),
(TYPE_ITA_E, 'ITA Type E (CEE 7/6)'),
(TYPE_ITA_F, 'ITA Type F (CEE 7/4)'),
(TYPE_ITA_EF, 'ITA Type E/F (CEE 7/7)'),
(TYPE_ITA_G, 'ITA Type G (BS 1363)'),
@@ -444,6 +451,7 @@ class PowerPortTypeChoices(ChoiceSet):
(TYPE_USB_MINI_B, 'USB Mini B'),
(TYPE_USB_MICRO_A, 'USB Micro A'),
(TYPE_USB_MICRO_B, 'USB Micro B'),
(TYPE_USB_MICRO_AB, 'USB Micro AB'),
(TYPE_USB_3_B, 'USB 3.0 Type B'),
(TYPE_USB_3_MICROB, 'USB 3.0 Micro B'),
)),
@@ -527,6 +535,7 @@ class PowerOutletTypeChoices(ChoiceSet):
TYPE_NEMA_L1560R = 'nema-l15-60r'
TYPE_NEMA_L2120R = 'nema-l21-20r'
TYPE_NEMA_L2130R = 'nema-l21-30r'
TYPE_NEMA_L2230R = 'nema-l22-30r'
# California style
TYPE_CS6360C = 'CS6360C'
TYPE_CS6364C = 'CS6364C'
@@ -546,6 +555,7 @@ class PowerOutletTypeChoices(ChoiceSet):
TYPE_ITA_M = 'ita-m'
TYPE_ITA_N = 'ita-n'
TYPE_ITA_O = 'ita-o'
TYPE_ITA_MULTISTANDARD = 'ita-multistandard'
# USB
TYPE_USB_A = 'usb-a'
TYPE_USB_MICROB = 'usb-micro-b'
@@ -624,6 +634,7 @@ class PowerOutletTypeChoices(ChoiceSet):
(TYPE_NEMA_L1560R, 'NEMA L15-60R'),
(TYPE_NEMA_L2120R, 'NEMA L21-20R'),
(TYPE_NEMA_L2130R, 'NEMA L21-30R'),
(TYPE_NEMA_L2230R, 'NEMA L22-30R'),
)),
('California Style', (
(TYPE_CS6360C, 'CS6360C'),
@@ -634,8 +645,8 @@ class PowerOutletTypeChoices(ChoiceSet):
(TYPE_CS8464C, 'CS8464C'),
)),
('ITA/International', (
(TYPE_ITA_E, 'ITA Type E (CEE7/5)'),
(TYPE_ITA_F, 'ITA Type F (CEE7/3)'),
(TYPE_ITA_E, 'ITA Type E (CEE 7/5)'),
(TYPE_ITA_F, 'ITA Type F (CEE 7/3)'),
(TYPE_ITA_G, 'ITA Type G (BS 1363)'),
(TYPE_ITA_H, 'ITA Type H'),
(TYPE_ITA_I, 'ITA Type I'),
@@ -645,6 +656,7 @@ class PowerOutletTypeChoices(ChoiceSet):
(TYPE_ITA_M, 'ITA Type M (BS 546)'),
(TYPE_ITA_N, 'ITA Type N'),
(TYPE_ITA_O, 'ITA Type O'),
(TYPE_ITA_MULTISTANDARD, 'ITA Multistandard'),
)),
('USB', (
(TYPE_USB_A, 'USB Type A'),
@@ -681,6 +693,18 @@ class PowerOutletFeedLegChoices(ChoiceSet):
# Interfaces
#
class InterfaceKindChoices(ChoiceSet):
KIND_PHYSICAL = 'physical'
KIND_VIRTUAL = 'virtual'
KIND_WIRELESS = 'wireless'
CHOICES = (
(KIND_PHYSICAL, 'Physical'),
(KIND_VIRTUAL, 'Virtual'),
(KIND_WIRELESS, 'Wireless'),
)
class InterfaceTypeChoices(ChoiceSet):
# Virtual
@@ -721,6 +745,7 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_80211AC = 'ieee802.11ac'
TYPE_80211AD = 'ieee802.11ad'
TYPE_80211AX = 'ieee802.11ax'
TYPE_802151 = 'ieee802.15.1'
# Cellular
TYPE_GSM = 'gsm'
@@ -832,6 +857,7 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_80211AC, 'IEEE 802.11ac'),
(TYPE_80211AD, 'IEEE 802.11ad'),
(TYPE_80211AX, 'IEEE 802.11ax'),
(TYPE_802151, 'IEEE 802.15.1 (Bluetooth)'),
)
),
(

View File

@@ -10,14 +10,14 @@ from tenancy.filtersets import TenancyFilterSet
from tenancy.models import Tenant
from utilities.choices import ColorChoices
from utilities.filters import (
MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, TreeNodeMultipleChoiceFilter,
ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter,
TreeNodeMultipleChoiceFilter,
)
from virtualization.models import Cluster
from .choices import *
from .constants import *
from .models import *
__all__ = (
'CableFilterSet',
'CableTerminationFilterSet',
@@ -480,12 +480,21 @@ class DeviceTypeFilterSet(PrimaryModelFilterSet):
class DeviceTypeComponentFilterSet(django_filters.FilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
devicetype_id = django_filters.ModelMultipleChoiceFilter(
queryset=DeviceType.objects.all(),
field_name='device_type_id',
label='Device type (ID)',
)
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(name__icontains=value)
class ConsolePortTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet):
@@ -852,6 +861,17 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
to_field_name='name',
label='Device (name)',
)
virtual_chassis_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__virtual_chassis',
queryset=VirtualChassis.objects.all(),
label='Virtual Chassis (ID)'
)
virtual_chassis = django_filters.ModelMultipleChoiceFilter(
field_name='device__virtual_chassis__name',
queryset=VirtualChassis.objects.all(),
to_field_name='name',
label='Virtual Chassis',
)
tag = TagFilter()
def search(self, queryset, name, value):
@@ -1184,6 +1204,10 @@ class CableFilterSet(PrimaryModelFilterSet):
method='search',
label='Search',
)
termination_a_type = ContentTypeFilter()
termination_a_id = MultiValueNumberFilter()
termination_b_type = ContentTypeFilter()
termination_b_id = MultiValueNumberFilter()
type = django_filters.MultipleChoiceFilter(
choices=CableTypeChoices
)
@@ -1228,7 +1252,7 @@ class CableFilterSet(PrimaryModelFilterSet):
class Meta:
model = Cable
fields = ['id', 'label', 'length', 'length_unit']
fields = ['id', 'label', 'length', 'length_unit', 'termination_a_id', 'termination_b_id']
def search(self, queryset, name, value):
if not value.strip():
@@ -1243,73 +1267,6 @@ class CableFilterSet(PrimaryModelFilterSet):
return queryset
class ConnectionFilterSet(BaseFilterSet):
def filter_site(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(device__site__slug=value)
def filter_device(self, queryset, name, value):
if not value:
return queryset
return queryset.filter(**{f'{name}__in': value})
class ConsoleConnectionFilterSet(ConnectionFilterSet):
site = django_filters.CharFilter(
method='filter_site',
label='Site (slug)',
)
device_id = MultiValueNumberFilter(
method='filter_device'
)
device = MultiValueCharFilter(
method='filter_device',
field_name='device__name'
)
class Meta:
model = ConsolePort
fields = ['name']
class PowerConnectionFilterSet(ConnectionFilterSet):
site = django_filters.CharFilter(
method='filter_site',
label='Site (slug)',
)
device_id = MultiValueNumberFilter(
method='filter_device'
)
device = MultiValueCharFilter(
method='filter_device',
field_name='device__name'
)
class Meta:
model = PowerPort
fields = ['name']
class InterfaceConnectionFilterSet(ConnectionFilterSet):
site = django_filters.CharFilter(
method='filter_site',
label='Site (slug)',
)
device_id = MultiValueNumberFilter(
method='filter_device'
)
device = MultiValueCharFilter(
method='filter_device',
field_name='device__name'
)
class Meta:
model = Interface
fields = []
class PowerPanelFilterSet(PrimaryModelFilterSet):
q = django_filters.CharFilter(
method='search',
@@ -1441,3 +1398,65 @@ class PowerFeedFilterSet(PrimaryModelFilterSet, CableTerminationFilterSet, PathE
Q(comments__icontains=value)
)
return queryset.filter(qs_filter)
#
# Connection filter sets
#
class ConnectionFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
site_id = MultiValueNumberFilter(
method='filter_connections',
field_name='device__site_id'
)
site = MultiValueCharFilter(
method='filter_connections',
field_name='device__site__slug'
)
device_id = MultiValueNumberFilter(
method='filter_connections',
field_name='device_id'
)
device = MultiValueCharFilter(
method='filter_connections',
field_name='device__name'
)
def filter_connections(self, queryset, name, value):
if not value:
return queryset
return queryset.filter(**{f'{name}__in': value})
def search(self, queryset, name, value):
if not value.strip():
return queryset
qs_filter = (
Q(device__name__icontains=value) |
Q(cable__label__icontains=value)
)
return queryset.filter(qs_filter)
class ConsoleConnectionFilterSet(ConnectionFilterSet):
class Meta:
model = ConsolePort
fields = ['name']
class PowerConnectionFilterSet(ConnectionFilterSet):
class Meta:
model = PowerPort
fields = ['name']
class InterfaceConnectionFilterSet(ConnectionFilterSet):
class Meta:
model = Interface
fields = []

View File

@@ -215,8 +215,7 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, CustomFieldModelForm)
required=False
)
class Meta:
model = Cable
class Meta(ConnectCableToDeviceForm.Meta):
fields = [
'termination_b_provider', 'termination_b_region', 'termination_b_site', 'termination_b_circuit',
'termination_b_id', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags',
@@ -277,8 +276,7 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, CustomFieldModelForm):
required=False
)
class Meta:
model = Cable
class Meta(ConnectCableToDeviceForm.Meta):
fields = [
'termination_b_location', 'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'label',
'color', 'length', 'length_unit', 'tags',

View File

@@ -92,12 +92,19 @@ class DeviceComponentFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
label=_('Location'),
fetch_trigger='open'
)
virtual_chassis_id = DynamicModelMultipleChoiceField(
queryset=VirtualChassis.objects.all(),
required=False,
label=_('Virtual Chassis'),
fetch_trigger='open'
)
device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
required=False,
query_params={
'site_id': '$site_id',
'location_id': '$location_id',
'virtual_chassis_id': '$virtual_chassis_id'
},
label=_('Device'),
fetch_trigger='open'
@@ -888,7 +895,7 @@ class ConsolePortFilterForm(DeviceComponentFilterForm):
field_groups = [
['q', 'tag'],
['name', 'label', 'type', 'speed'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
]
type = forms.MultipleChoiceField(
choices=ConsolePortTypeChoices,
@@ -908,7 +915,7 @@ class ConsoleServerPortFilterForm(DeviceComponentFilterForm):
field_groups = [
['q', 'tag'],
['name', 'label', 'type', 'speed'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
]
type = forms.MultipleChoiceField(
choices=ConsolePortTypeChoices,
@@ -928,7 +935,7 @@ class PowerPortFilterForm(DeviceComponentFilterForm):
field_groups = [
['q', 'tag'],
['name', 'label', 'type'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
]
type = forms.MultipleChoiceField(
choices=PowerPortTypeChoices,
@@ -943,7 +950,7 @@ class PowerOutletFilterForm(DeviceComponentFilterForm):
field_groups = [
['q', 'tag'],
['name', 'label', 'type'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
]
type = forms.MultipleChoiceField(
choices=PowerOutletTypeChoices,
@@ -957,9 +964,14 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
model = Interface
field_groups = [
['q', 'tag'],
['name', 'label', 'type', 'enabled', 'mgmt_only', 'mac_address'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
['name', 'label', 'kind', 'type', 'enabled', 'mgmt_only', 'mac_address'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
]
kind = forms.MultipleChoiceField(
choices=InterfaceKindChoices,
required=False,
widget=StaticSelectMultiple()
)
type = forms.MultipleChoiceField(
choices=InterfaceTypeChoices,
required=False,
@@ -988,7 +1000,7 @@ class FrontPortFilterForm(DeviceComponentFilterForm):
field_groups = [
['q', 'tag'],
['name', 'label', 'type', 'color'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
]
model = FrontPort
type = forms.MultipleChoiceField(
@@ -1007,7 +1019,7 @@ class RearPortFilterForm(DeviceComponentFilterForm):
field_groups = [
['q', 'tag'],
['name', 'label', 'type', 'color'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
]
type = forms.MultipleChoiceField(
choices=PortTypeChoices,
@@ -1025,7 +1037,7 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
field_groups = [
['q', 'tag'],
['name', 'label'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
]
tag = TagFilterField(model)
@@ -1035,7 +1047,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
field_groups = [
['q', 'tag'],
['name', 'label', 'manufacturer_id', 'serial', 'asset_tag', 'discovered'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
]
manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
@@ -1063,6 +1075,11 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
#
class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form):
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -1090,6 +1107,11 @@ class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form):
class PowerConnectionFilterForm(BootstrapMixin, forms.Form):
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -1117,6 +1139,11 @@ class PowerConnectionFilterForm(BootstrapMixin, forms.Form):
class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form):
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,

View File

@@ -38,6 +38,7 @@ __all__ = (
'LocationForm',
'ManufacturerForm',
'PlatformForm',
'PopulateDeviceBayForm',
'PowerFeedForm',
'PowerOutletForm',
'PowerOutletTemplateForm',
@@ -52,6 +53,7 @@ __all__ = (
'RegionForm',
'SiteForm',
'SiteGroupForm',
'VCMemberSelectForm',
'VirtualChassisForm',
)

View File

@@ -117,12 +117,18 @@ class VirtualChassisCreateForm(BootstrapMixin, CustomFieldModelForm):
'name', 'domain', 'region', 'site_group', 'site', 'rack', 'members', 'initial_position', 'tags',
]
def clean(self):
if self.cleaned_data['members'] and self.cleaned_data['initial_position'] is None:
raise forms.ValidationError({
'initial_position': "A position must be specified for the first VC member."
})
def save(self, *args, **kwargs):
instance = super().save(*args, **kwargs)
# Assign VC members
if instance.pk:
initial_position = self.cleaned_data.get('initial_position') or 1
if instance.pk and self.cleaned_data['members']:
initial_position = self.cleaned_data.get('initial_position', 1)
for i, member in enumerate(self.cleaned_data['members'], start=initial_position):
member.virtual_chassis = instance
member.vc_position = i

View File

@@ -1,3 +1,4 @@
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
@@ -39,6 +40,9 @@ class PowerPanel(PrimaryModel):
name = models.CharField(
max_length=100
)
images = GenericRelation(
to='extras.ImageAttachment'
)
objects = RestrictedQuerySet.as_manager()

View File

@@ -112,6 +112,9 @@ class RackElevationSVG:
)
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):
rect = drawing.rect(start, end, class_="slot blocked")
@@ -129,17 +132,24 @@ class RackElevationSVG:
)
image.fit(scale='slice')
drawing.add(image)
drawing.add(drawing.text(str(device), insert=text, stroke='black',
stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label'))
drawing.add(drawing.text(str(device), insert=text, fill='white', class_='device-image-label'))
@staticmethod
def _draw_empty(drawing, rack, start, end, text, id_, face_id, class_, reservation):
link_url = '{}?{}'.format(
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='{}?{}'.format(
reverse('dcim:device_add'),
urlencode({'rack': rack.pk, 'site': rack.site.pk, 'face': face_id, 'position': id_})
),
target='_top'
)
drawing.a(href=link_url, target='_top')
)
if reservation:
link.set_desc('{}{} · {}'.format(
@@ -432,15 +442,16 @@ class CableTraceSVG:
parent_objects.append(parent_object)
# Near end termination
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)
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 (either a Cable or attachment to a ProviderNetwork)
if connector is not None:

View File

@@ -43,6 +43,7 @@ class ConsoleConnectionTable(BaseTable):
class Meta(BaseTable.Meta):
model = ConsolePort
fields = ('device', 'name', 'console_server', 'console_server_port', 'reachable')
exclude = ('id', )
class PowerConnectionTable(BaseTable):
@@ -73,6 +74,7 @@ class PowerConnectionTable(BaseTable):
class Meta(BaseTable.Meta):
model = PowerPort
fields = ('device', 'name', 'pdu', 'outlet', 'reachable')
exclude = ('id', )
class InterfaceConnectionTable(BaseTable):
@@ -106,3 +108,4 @@ class InterfaceConnectionTable(BaseTable):
class Meta(BaseTable.Meta):
model = Interface
fields = ('device_a', 'interface_a', 'device_b', 'interface_b', 'reachable')
exclude = ('id', )

View File

@@ -2,7 +2,7 @@ import django_tables2 as tables
from django_tables2.utils import Accessor
from dcim.models import Cable
from utilities.tables import BaseTable, ChoiceFieldColumn, ColorColumn, TagColumn, ToggleColumn
from utilities.tables import BaseTable, ChoiceFieldColumn, ColorColumn, TagColumn, TemplateColumn, ToggleColumn
from .template_code import CABLE_LENGTH, CABLE_TERMINATION_PARENT
__all__ = (
@@ -16,10 +16,6 @@ __all__ = (
class CableTable(BaseTable):
pk = ToggleColumn()
id = tables.Column(
linkify=True,
verbose_name='ID'
)
termination_a_parent = tables.TemplateColumn(
template_code=CABLE_TERMINATION_PARENT,
accessor=Accessor('termination_a'),
@@ -45,7 +41,7 @@ class CableTable(BaseTable):
verbose_name='Termination B'
)
status = ChoiceFieldColumn()
length = tables.TemplateColumn(
length = TemplateColumn(
template_code=CABLE_LENGTH,
order_by='_abs_length'
)

View File

@@ -9,7 +9,7 @@ from dcim.models import (
from tenancy.tables import TenantColumn
from utilities.tables import (
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn,
MarkdownColumn, TagColumn, ToggleColumn,
MarkdownColumn, TagColumn, TemplateColumn, ToggleColumn,
)
from .template_code import (
CABLETERMINATION, CONSOLEPORT_BUTTONS, CONSOLESERVERPORT_BUTTONS, DEVICE_LINK, DEVICEBAY_BUTTONS, DEVICEBAY_STATUS,
@@ -53,6 +53,14 @@ def get_cabletermination_row_class(record):
return ''
def get_interface_row_class(record):
if not record.enabled:
return 'danger'
elif record.is_virtual:
return 'primary'
return get_cabletermination_row_class(record)
def get_interface_state_attribute(record):
"""
Get interface enabled state as string to attach to <tr/> DOM element.
@@ -88,7 +96,7 @@ class DeviceRoleTable(BaseTable):
class Meta(BaseTable.Meta):
model = DeviceRole
fields = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'slug', 'actions')
fields = ('pk', 'id', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'slug', 'actions')
default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'actions')
@@ -116,7 +124,7 @@ class PlatformTable(BaseTable):
class Meta(BaseTable.Meta):
model = Platform
fields = (
'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'napalm_args',
'pk', 'id', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'napalm_args',
'description', 'actions',
)
default_columns = (
@@ -196,7 +204,7 @@ class DeviceTable(BaseTable):
class Meta(BaseTable.Meta):
model = Device
fields = (
'pk', 'name', 'status', 'tenant', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial',
'pk', 'id', 'name', 'status', 'tenant', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial',
'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'primary_ip', 'primary_ip4', 'primary_ip6',
'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags',
)
@@ -227,7 +235,7 @@ class DeviceImportTable(BaseTable):
class Meta(BaseTable.Meta):
model = Device
fields = ('name', 'status', 'tenant', 'site', 'rack', 'position', 'device_role', 'device_type')
fields = ('id', 'name', 'status', 'tenant', 'site', 'rack', 'position', 'device_role', 'device_type')
empty_text = False
@@ -258,7 +266,7 @@ class CableTerminationTable(BaseTable):
orderable=False,
verbose_name='Cable Color'
)
cable_peer = tables.TemplateColumn(
cable_peer = TemplateColumn(
accessor='_cable_peer',
template_code=CABLETERMINATION,
orderable=False,
@@ -268,7 +276,7 @@ class CableTerminationTable(BaseTable):
class PathEndpointTable(CableTerminationTable):
connection = tables.TemplateColumn(
connection = TemplateColumn(
accessor='_path.last_node',
template_code=CABLETERMINATION,
verbose_name='Connection',
@@ -290,7 +298,7 @@ class ConsolePortTable(DeviceComponentTable, PathEndpointTable):
class Meta(DeviceComponentTable.Meta):
model = ConsolePort
fields = (
'pk', 'name', 'device', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color',
'pk', 'id', 'name', 'device', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color',
'cable_peer', 'connection', 'tags',
)
default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description')
@@ -311,7 +319,7 @@ class DeviceConsolePortTable(ConsolePortTable):
class Meta(DeviceComponentTable.Meta):
model = ConsolePort
fields = (
'pk', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color',
'pk', 'id', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color',
'cable_peer', 'connection', 'tags', 'actions'
)
default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions')
@@ -334,7 +342,7 @@ class ConsoleServerPortTable(DeviceComponentTable, PathEndpointTable):
class Meta(DeviceComponentTable.Meta):
model = ConsoleServerPort
fields = (
'pk', 'name', 'device', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color',
'pk', 'id', 'name', 'device', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color',
'cable_peer', 'connection', 'tags',
)
default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description')
@@ -356,7 +364,7 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable):
class Meta(DeviceComponentTable.Meta):
model = ConsoleServerPort
fields = (
'pk', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color',
'pk', 'id', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color',
'cable_peer', 'connection', 'tags', 'actions',
)
default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions')
@@ -379,7 +387,7 @@ class PowerPortTable(DeviceComponentTable, PathEndpointTable):
class Meta(DeviceComponentTable.Meta):
model = PowerPort
fields = (
'pk', 'name', 'device', 'label', 'type', 'description', 'mark_connected', 'maximum_draw', 'allocated_draw',
'pk', 'id', 'name', 'device', 'label', 'type', 'description', 'mark_connected', 'maximum_draw', 'allocated_draw',
'cable', 'cable_color', 'cable_peer', 'connection', 'tags',
)
default_columns = ('pk', 'name', 'device', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description')
@@ -401,7 +409,7 @@ class DevicePowerPortTable(PowerPortTable):
class Meta(DeviceComponentTable.Meta):
model = PowerPort
fields = (
'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'mark_connected', 'cable',
'pk', 'id', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'mark_connected', 'cable',
'cable_color', 'cable_peer', 'connection', 'tags', 'actions',
)
default_columns = (
@@ -430,7 +438,7 @@ class PowerOutletTable(DeviceComponentTable, PathEndpointTable):
class Meta(DeviceComponentTable.Meta):
model = PowerOutlet
fields = (
'pk', 'name', 'device', 'label', 'type', 'description', 'power_port', 'feed_leg', 'mark_connected', 'cable',
'pk', 'id', 'name', 'device', 'label', 'type', 'description', 'power_port', 'feed_leg', 'mark_connected', 'cable',
'cable_color', 'cable_peer', 'connection', 'tags',
)
default_columns = ('pk', 'name', 'device', 'label', 'type', 'power_port', 'feed_leg', 'description')
@@ -451,7 +459,7 @@ class DevicePowerOutletTable(PowerOutletTable):
class Meta(DeviceComponentTable.Meta):
model = PowerOutlet
fields = (
'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'mark_connected', 'cable',
'pk', 'id', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'mark_connected', 'cable',
'cable_color', 'cable_peer', 'connection', 'tags', 'actions',
)
default_columns = (
@@ -470,7 +478,7 @@ class BaseInterfaceTable(BaseTable):
verbose_name='IP Addresses'
)
untagged_vlan = tables.Column(linkify=True)
tagged_vlans = tables.TemplateColumn(
tagged_vlans = TemplateColumn(
template_code=INTERFACE_TAGGED_VLANS,
orderable=False,
verbose_name='Tagged VLANs'
@@ -492,7 +500,7 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable
class Meta(DeviceComponentTable.Meta):
model = Interface
fields = (
'pk', 'name', 'device', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address',
'pk', 'id', 'name', 'device', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address',
'description', 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'connection', 'tags', 'ip_addresses',
'untagged_vlan', 'tagged_vlans',
)
@@ -501,8 +509,8 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable
class DeviceInterfaceTable(InterfaceTable):
name = tables.TemplateColumn(
template_code='<i class="mdi mdi-{% if iface.mgmt_only %}wrench{% elif iface.is_lag %}drag-horizontal-variant'
'{% elif iface.is_virtual %}circle{% elif iface.is_wireless %}wifi{% else %}ethernet'
template_code='<i class="mdi mdi-{% if record.mgmt_only %}wrench{% elif record.is_lag %}reorder-horizontal'
'{% elif record.is_virtual %}circle{% elif record.is_wireless %}wifi{% else %}ethernet'
'{% endif %}"></i> <a href="{{ record.get_absolute_url }}">{{ value }}</a>',
order_by=Accessor('_name'),
attrs={'td': {'class': 'text-nowrap'}}
@@ -524,7 +532,7 @@ class DeviceInterfaceTable(InterfaceTable):
class Meta(DeviceComponentTable.Meta):
model = Interface
fields = (
'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address',
'pk', 'id', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address',
'description', 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'connection', 'tags', 'ip_addresses',
'untagged_vlan', 'tagged_vlans', 'actions',
)
@@ -534,7 +542,7 @@ class DeviceInterfaceTable(InterfaceTable):
'cable', 'connection', 'actions',
)
row_attrs = {
'class': get_cabletermination_row_class,
'class': get_interface_row_class,
'data-name': lambda record: record.name,
'data-enabled': get_interface_state_attribute,
}
@@ -561,7 +569,7 @@ class FrontPortTable(DeviceComponentTable, CableTerminationTable):
class Meta(DeviceComponentTable.Meta):
model = FrontPort
fields = (
'pk', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description',
'pk', 'id', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description',
'mark_connected', 'cable', 'cable_color', 'cable_peer', 'tags',
)
default_columns = (
@@ -585,7 +593,7 @@ class DeviceFrontPortTable(FrontPortTable):
class Meta(DeviceComponentTable.Meta):
model = FrontPort
fields = (
'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'mark_connected', 'cable',
'pk', 'id', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'mark_connected', 'cable',
'cable_color', 'cable_peer', 'tags', 'actions',
)
default_columns = (
@@ -612,7 +620,7 @@ class RearPortTable(DeviceComponentTable, CableTerminationTable):
class Meta(DeviceComponentTable.Meta):
model = RearPort
fields = (
'pk', 'name', 'device', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable',
'pk', 'id', 'name', 'device', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable',
'cable_color', 'cable_peer', 'tags',
)
default_columns = ('pk', 'name', 'device', 'label', 'type', 'color', 'description')
@@ -634,7 +642,7 @@ class DeviceRearPortTable(RearPortTable):
class Meta(DeviceComponentTable.Meta):
model = RearPort
fields = (
'pk', 'name', 'label', 'type', 'positions', 'description', 'mark_connected', 'cable', 'cable_color',
'pk', 'id', 'name', 'label', 'type', 'positions', 'description', 'mark_connected', 'cable', 'cable_color',
'cable_peer', 'tags', 'actions',
)
default_columns = (
@@ -653,7 +661,8 @@ class DeviceBayTable(DeviceComponentTable):
}
)
status = tables.TemplateColumn(
template_code=DEVICEBAY_STATUS
template_code=DEVICEBAY_STATUS,
order_by=Accessor('installed_device__status')
)
installed_device = tables.Column(
linkify=True
@@ -664,7 +673,7 @@ class DeviceBayTable(DeviceComponentTable):
class Meta(DeviceComponentTable.Meta):
model = DeviceBay
fields = ('pk', 'name', 'device', 'label', 'status', 'installed_device', 'description', 'tags')
fields = ('pk', 'id', 'name', 'device', 'label', 'status', 'installed_device', 'description', 'tags')
default_columns = ('pk', 'name', 'device', 'label', 'status', 'installed_device', 'description')
@@ -684,7 +693,7 @@ class DeviceDeviceBayTable(DeviceBayTable):
class Meta(DeviceComponentTable.Meta):
model = DeviceBay
fields = (
'pk', 'name', 'label', 'status', 'installed_device', 'description', 'tags', 'actions',
'pk', 'id', 'name', 'label', 'status', 'installed_device', 'description', 'tags', 'actions',
)
default_columns = (
'pk', 'name', 'label', 'status', 'installed_device', 'description', 'actions',
@@ -710,7 +719,7 @@ class InventoryItemTable(DeviceComponentTable):
class Meta(BaseTable.Meta):
model = InventoryItem
fields = (
'pk', 'name', 'device', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description',
'pk', 'id', 'name', 'device', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description',
'discovered', 'tags',
)
default_columns = ('pk', 'name', 'device', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag')
@@ -731,7 +740,7 @@ class DeviceInventoryItemTable(InventoryItemTable):
class Meta(BaseTable.Meta):
model = InventoryItem
fields = (
'pk', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'discovered',
'pk', 'id', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'discovered',
'tags', 'actions',
)
default_columns = (
@@ -763,5 +772,5 @@ class VirtualChassisTable(BaseTable):
class Meta(BaseTable.Meta):
model = VirtualChassis
fields = ('pk', 'name', 'domain', 'master', 'member_count', 'tags')
fields = ('pk', 'id', 'name', 'domain', 'master', 'member_count', 'tags')
default_columns = ('pk', 'name', 'domain', 'master', 'member_count')

View File

@@ -46,6 +46,9 @@ class ManufacturerTable(BaseTable):
class Meta(BaseTable.Meta):
model = Manufacturer
fields = (
'pk', 'id', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'actions',
)
default_columns = (
'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'actions',
)
@@ -76,7 +79,7 @@ class DeviceTypeTable(BaseTable):
class Meta(BaseTable.Meta):
model = DeviceType
fields = (
'pk', 'model', 'manufacturer', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
'pk', 'id', 'model', 'manufacturer', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
'comments', 'instance_count', 'tags',
)
default_columns = (
@@ -90,10 +93,16 @@ class DeviceTypeTable(BaseTable):
class ComponentTemplateTable(BaseTable):
pk = ToggleColumn()
id = tables.Column(
verbose_name='ID'
)
name = tables.Column(
order_by=('_name',)
)
class Meta(BaseTable.Meta):
exclude = ('id', )
class ConsolePortTemplateTable(ComponentTemplateTable):
actions = ButtonsColumn(
@@ -102,7 +111,7 @@ class ConsolePortTemplateTable(ComponentTemplateTable):
return_url_extra='%23tab_consoleports'
)
class Meta(BaseTable.Meta):
class Meta(ComponentTemplateTable.Meta):
model = ConsolePortTemplate
fields = ('pk', 'name', 'label', 'type', 'description', 'actions')
empty_text = "None"
@@ -115,7 +124,7 @@ class ConsoleServerPortTemplateTable(ComponentTemplateTable):
return_url_extra='%23tab_consoleserverports'
)
class Meta(BaseTable.Meta):
class Meta(ComponentTemplateTable.Meta):
model = ConsoleServerPortTemplate
fields = ('pk', 'name', 'label', 'type', 'description', 'actions')
empty_text = "None"
@@ -128,7 +137,7 @@ class PowerPortTemplateTable(ComponentTemplateTable):
return_url_extra='%23tab_powerports'
)
class Meta(BaseTable.Meta):
class Meta(ComponentTemplateTable.Meta):
model = PowerPortTemplate
fields = ('pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'actions')
empty_text = "None"
@@ -141,7 +150,7 @@ class PowerOutletTemplateTable(ComponentTemplateTable):
return_url_extra='%23tab_poweroutlets'
)
class Meta(BaseTable.Meta):
class Meta(ComponentTemplateTable.Meta):
model = PowerOutletTemplate
fields = ('pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'actions')
empty_text = "None"
@@ -157,7 +166,7 @@ class InterfaceTemplateTable(ComponentTemplateTable):
return_url_extra='%23tab_interfaces'
)
class Meta(BaseTable.Meta):
class Meta(ComponentTemplateTable.Meta):
model = InterfaceTemplate
fields = ('pk', 'name', 'label', 'mgmt_only', 'type', 'description', 'actions')
empty_text = "None"
@@ -174,7 +183,7 @@ class FrontPortTemplateTable(ComponentTemplateTable):
return_url_extra='%23tab_frontports'
)
class Meta(BaseTable.Meta):
class Meta(ComponentTemplateTable.Meta):
model = FrontPortTemplate
fields = ('pk', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', 'actions')
empty_text = "None"
@@ -188,7 +197,7 @@ class RearPortTemplateTable(ComponentTemplateTable):
return_url_extra='%23tab_rearports'
)
class Meta(BaseTable.Meta):
class Meta(ComponentTemplateTable.Meta):
model = RearPortTemplate
fields = ('pk', 'name', 'label', 'type', 'color', 'positions', 'description', 'actions')
empty_text = "None"
@@ -201,7 +210,7 @@ class DeviceBayTemplateTable(ComponentTemplateTable):
return_url_extra='%23tab_devicebays'
)
class Meta(BaseTable.Meta):
class Meta(ComponentTemplateTable.Meta):
model = DeviceBayTemplate
fields = ('pk', 'name', 'label', 'description', 'actions')
empty_text = "None"

View File

@@ -33,7 +33,7 @@ class PowerPanelTable(BaseTable):
class Meta(BaseTable.Meta):
model = PowerPanel
fields = ('pk', 'name', 'site', 'location', 'powerfeed_count', 'tags')
fields = ('pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'tags')
default_columns = ('pk', 'name', 'site', 'location', 'powerfeed_count')
@@ -70,7 +70,7 @@ class PowerFeedTable(CableTerminationTable):
class Meta(BaseTable.Meta):
model = PowerFeed
fields = (
'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase',
'pk', 'id', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase',
'max_utilization', 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'connection', 'available_power',
'comments', 'tags',
)

View File

@@ -28,7 +28,7 @@ class RackRoleTable(BaseTable):
class Meta(BaseTable.Meta):
model = RackRole
fields = ('pk', 'name', 'rack_count', 'color', 'description', 'slug', 'actions')
fields = ('pk', 'id', 'name', 'rack_count', 'color', 'description', 'slug', 'actions')
default_columns = ('pk', 'name', 'rack_count', 'color', 'description', 'actions')
@@ -72,12 +72,20 @@ class RackTable(BaseTable):
tags = TagColumn(
url_name='dcim:rack_list'
)
outer_width = tables.TemplateColumn(
template_code="{{ record.outer_width }} {{ record.outer_unit }}",
verbose_name='Outer Width'
)
outer_depth = tables.TemplateColumn(
template_code="{{ record.outer_depth }} {{ record.outer_unit }}",
verbose_name='Outer Depth'
)
class Meta(BaseTable.Meta):
model = Rack
fields = (
'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type',
'width', 'u_height', 'comments', 'device_count', 'get_utilization', 'get_power_utilization', 'tags',
'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type',
'width', 'outer_width', 'outer_depth', 'u_height', 'comments', 'device_count', 'get_utilization', 'get_power_utilization', 'tags',
)
default_columns = (
'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count',
@@ -115,7 +123,7 @@ class RackReservationTable(BaseTable):
class Meta(BaseTable.Meta):
model = RackReservation
fields = (
'pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'tags',
'pk', 'id', 'reservation', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'tags',
'actions',
)
default_columns = (

View File

@@ -33,7 +33,7 @@ class RegionTable(BaseTable):
class Meta(BaseTable.Meta):
model = Region
fields = ('pk', 'name', 'slug', 'site_count', 'description', 'actions')
fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'actions')
default_columns = ('pk', 'name', 'site_count', 'description', 'actions')
@@ -55,7 +55,7 @@ class SiteGroupTable(BaseTable):
class Meta(BaseTable.Meta):
model = SiteGroup
fields = ('pk', 'name', 'slug', 'site_count', 'description', 'actions')
fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'actions')
default_columns = ('pk', 'name', 'site_count', 'description', 'actions')
@@ -84,7 +84,7 @@ class SiteTable(BaseTable):
class Meta(BaseTable.Meta):
model = Site
fields = (
'pk', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asn', 'time_zone', 'description',
'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asn', 'time_zone', 'description',
'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone',
'contact_email', 'comments', 'tags',
)
@@ -120,5 +120,5 @@ class LocationTable(BaseTable):
class Meta(BaseTable.Meta):
model = Location
fields = ('pk', 'name', 'site', 'rack_count', 'device_count', 'description', 'slug', 'actions')
fields = ('pk', 'id', 'name', 'site', 'rack_count', 'device_count', 'description', 'slug', 'actions')
default_columns = ('pk', 'name', 'site', 'rack_count', 'device_count', 'description', 'actions')

View File

@@ -5,13 +5,11 @@ CABLETERMINATION = """
<i class="mdi mdi-chevron-right"></i>
{% endif %}
<a href="{{ value.get_absolute_url }}">{{ value }}</a>
{% else %}
&mdash;
{% endif %}
"""
CABLE_LENGTH = """
{% if record.length %}{{ record.length }} {{ record.get_length_unit_display }}{% else %}&mdash;{% endif %}
{% if record.length %}{{ record.length }} {{ record.get_length_unit_display }}{% endif %}
"""
CABLE_TERMINATION_PARENT = """
@@ -42,17 +40,13 @@ DEVICEBAY_STATUS = """
INTERFACE_IPADDRESSES = """
<div class="table-badge-group">
{% for ip in record.ip_addresses.all %}
<a
class="table-badge{% if ip.status != 'active' %} badge bg-{{ ip.get_status_class }}{% elif ip.role %} badge bg-{{ ip.get_role_class }}{% endif %}"
href="{{ ip.get_absolute_url }}"
{% if ip.status != 'active'%}data-bs-toggle="tooltip" data-bs-placement="left" title="{{ ip.get_status_display }}"
{% elif ip.role %}data-bs-toggle="tooltip" data-bs-placement="left" title="{{ ip.get_role_display }}"
{% endif %}
>
{{ ip }}
</a>
{% endfor %}
{% for ip in record.ip_addresses.all %}
{% if ip.status != 'active' %}
<a href="{{ ip.get_absolute_url }}" class="table-badge badge bg-{{ ip.get_status_class }}" data-bs-toggle="tooltip" data-bs-placement="left" title="{{ ip.get_status_display }}">{{ ip }}</a>
{% else %}
<a href="{{ ip.get_absolute_url }}" class="table-badge">{{ ip }}</a>
{% endif %}
{% endfor %}
</div>
"""
@@ -63,8 +57,6 @@ INTERFACE_TAGGED_VLANS = """
{% endfor %}
{% elif record.mode == 'tagged-all' %}
All
{% else %}
&mdash;
{% endif %}
"""

View File

@@ -1,4 +1,5 @@
from django.contrib.auth.models import User
from django.test import override_settings
from django.urls import reverse
from rest_framework import status
@@ -583,6 +584,12 @@ class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase):
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
)
power_port_templates = (
PowerPortTemplate(device_type=devicetype, name='Power Port Template 1'),
PowerPortTemplate(device_type=devicetype, name='Power Port Template 2'),
)
PowerPortTemplate.objects.bulk_create(power_port_templates)
power_outlet_templates = (
PowerOutletTemplate(device_type=devicetype, name='Power Outlet Template 1'),
PowerOutletTemplate(device_type=devicetype, name='Power Outlet Template 2'),
@@ -594,14 +601,17 @@ class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase):
{
'device_type': devicetype.pk,
'name': 'Power Outlet Template 4',
'power_port': power_port_templates[0].pk,
},
{
'device_type': devicetype.pk,
'name': 'Power Outlet Template 5',
'power_port': power_port_templates[1].pk,
},
{
'device_type': devicetype.pk,
'name': 'Power Outlet Template 6',
'power_port': None,
},
]
@@ -1032,14 +1042,17 @@ class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa
{
'device': device.pk,
'name': 'Console Port 4',
'speed': 9600,
},
{
'device': device.pk,
'name': 'Console Port 5',
'speed': 115200,
},
{
'device': device.pk,
'name': 'Console Port 6',
'speed': None,
},
]
@@ -1071,14 +1084,17 @@ class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIView
{
'device': device.pk,
'name': 'Console Server Port 4',
'speed': 9600,
},
{
'device': device.pk,
'name': 'Console Server Port 5',
'speed': 115200,
},
{
'device': device.pk,
'name': 'Console Server Port 6',
'speed': None,
},
]
@@ -1138,6 +1154,12 @@ class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa
devicerole = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1', color='ff0000')
device = Device.objects.create(device_type=devicetype, device_role=devicerole, name='Device 1', site=site)
power_ports = (
PowerPort(device=device, name='Power Port 1'),
PowerPort(device=device, name='Power Port 2'),
)
PowerPort.objects.bulk_create(power_ports)
power_outlets = (
PowerOutlet(device=device, name='Power Outlet 1'),
PowerOutlet(device=device, name='Power Outlet 2'),
@@ -1149,14 +1171,17 @@ class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa
{
'device': device.pk,
'name': 'Power Outlet 4',
'power_port': power_ports[0].pk,
},
{
'device': device.pk,
'name': 'Power Outlet 5',
'power_port': power_ports[1].pk,
},
{
'device': device.pk,
'name': 'Power Outlet 6',
'power_port': None,
},
]
@@ -1490,45 +1515,40 @@ class ConnectedDeviceTest(APITestCase):
super().setUp()
self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
self.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
self.devicetype1 = DeviceType.objects.create(
manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
)
self.devicetype2 = DeviceType.objects.create(
manufacturer=manufacturer, model='Test Device Type 2', slug='test-device-type-2'
)
self.devicerole1 = DeviceRole.objects.create(
name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
)
self.devicerole2 = DeviceRole.objects.create(
name='Test Device Role 2', slug='test-device-role-2', color='00ff00'
)
site = Site.objects.create(name='Site 1', slug='site-1')
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1', color='ff0000')
self.device1 = Device.objects.create(
device_type=self.devicetype1, device_role=self.devicerole1, name='TestDevice1', site=self.site1
device_type=devicetype, device_role=devicerole, name='TestDevice1', site=site
)
self.device2 = Device.objects.create(
device_type=self.devicetype1, device_role=self.devicerole1, name='TestDevice2', site=self.site1
device_type=devicetype, device_role=devicerole, name='TestDevice2', site=site
)
self.interface1 = Interface.objects.create(device=self.device1, name='eth0')
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.save()
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_get_connected_device(self):
url = reverse('dcim-api:connected-device-list')
response = self.client.get(url + '?peer_device=TestDevice2&peer_interface=eth0', **self.header)
url_params = f'?peer_device={self.device1.name}&peer_interface={self.interface1.name}'
response = self.client.get(url + url_params, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['name'], self.device1.name)
self.assertEqual(response.data['name'], self.device2.name)
url_params = f'?peer_device={self.device1.name}&peer_interface={self.interface3.name}'
response = self.client.get(url + url_params, **self.header)
self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)
class VirtualChassisTest(APIViewTestCases.APIViewTestCase):
model = VirtualChassis
brief_fields = ['id', 'master', 'member_count', 'name', 'url']
brief_fields = ['display', 'id', 'master', 'member_count', 'name', 'url']
@classmethod
def setUpTestData(cls):

View File

@@ -2048,6 +2048,11 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
)
Device.objects.bulk_create(devices)
# VirtualChassis assignment for filtering
virtual_chassis = VirtualChassis.objects.create(master=devices[0])
Device.objects.filter(pk=devices[0].pk).update(virtual_chassis=virtual_chassis, vc_position=1, vc_priority=1)
Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis, vc_position=2, vc_priority=2)
interfaces = (
Interface(device=devices[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'),
Interface(device=devices[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'),
@@ -2157,6 +2162,10 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'location': [locations[0].slug, locations[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_virtual_chassis_id(self):
params = {'virtual_chassis_id': [VirtualChassis.objects.first().pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_device(self):
devices = Device.objects.all()[:2]
params = {'device_id': [devices[0].pk, devices[1].pk]}
@@ -2851,6 +2860,9 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
)
Interface.objects.bulk_create(interfaces)
console_port = ConsolePort.objects.create(device=devices[0], name='Console Port 1')
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, status=CableStatusChoices.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, status=CableStatusChoices.STATUS_CONNECTED, color='aa1409', length=20, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
@@ -2858,6 +2870,7 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
Cable(termination_a=interfaces[7], termination_b=interfaces[8], label='Cable 4', type=CableTypeChoices.TYPE_CAT5E, status=CableStatusChoices.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, status=CableStatusChoices.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, status=CableStatusChoices.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()
def test_label(self):
params = {'label': ['Cable 1', 'Cable 2']}
@@ -2877,7 +2890,7 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
def test_status(self):
params = {'status': [CableStatusChoices.STATUS_CONNECTED]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'status': [CableStatusChoices.STATUS_PLANNED]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
@@ -2888,30 +2901,44 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
def test_device(self):
devices = Device.objects.all()[:2]
params = {'device_id': [devices[0].pk, devices[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'device': [devices[0].name, devices[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_rack(self):
racks = Rack.objects.all()[:2]
params = {'rack_id': [racks[0].pk, racks[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
params = {'rack': [racks[0].name, racks[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
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]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
params = {'site': [site[0].slug, site[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
def test_tenant(self):
tenant = Tenant.objects.all()[:2]
params = {'tenant_id': [tenant[0].pk, tenant[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
params = {'tenant': [tenant[0].slug, tenant[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
def test_termination_types(self):
params = {'termination_a_type': 'dcim.consoleport'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'termination_b_type': 'dcim.consoleserverport'}
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]
params = {
'termination_a_type': 'dcim.interface',
'termination_a_id': list(interface_ids),
}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
class PowerPanelTestCase(TestCase, ChangeLoggedFilterSetTests):

View File

@@ -1,6 +1,6 @@
from django.urls import path
from extras.views import ImageAttachmentEditView, ObjectChangeLogView, ObjectJournalView
from extras.views import ObjectChangeLogView, ObjectJournalView
from ipam.views import ServiceEditView
from utilities.views import SlugRedirectView
from . import views
@@ -43,7 +43,6 @@ urlpatterns = [
path('sites/<int:pk>/delete/', views.SiteDeleteView.as_view(), name='site_delete'),
path('sites/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}),
path('sites/<int:pk>/journal/', ObjectJournalView.as_view(), name='site_journal', kwargs={'model': Site}),
path('sites/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}),
# Locations
path('locations/', views.LocationListView.as_view(), name='location_list'),
@@ -55,7 +54,6 @@ urlpatterns = [
path('locations/<int:pk>/edit/', views.LocationEditView.as_view(), name='location_edit'),
path('locations/<int:pk>/delete/', views.LocationDeleteView.as_view(), name='location_delete'),
path('locations/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='location_changelog', kwargs={'model': Location}),
path('locations/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='location_add_image', kwargs={'model': Location}),
# Rack roles
path('rack-roles/', views.RackRoleListView.as_view(), name='rackrole_list'),
@@ -92,7 +90,6 @@ urlpatterns = [
path('racks/<int:pk>/delete/', views.RackDeleteView.as_view(), name='rack_delete'),
path('racks/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rack_changelog', kwargs={'model': Rack}),
path('racks/<int:pk>/journal/', ObjectJournalView.as_view(), name='rack_journal', kwargs={'model': Rack}),
path('racks/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}),
# Manufacturers
path('manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'),
@@ -229,7 +226,6 @@ urlpatterns = [
path('devices/<int:pk>/lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
path('devices/<int:pk>/config/', views.DeviceConfigView.as_view(), name='device_config'),
path('devices/<int:device>/services/assign/', ServiceEditView.as_view(), name='device_service_assign'),
path('devices/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}),
# Console ports
path('console-ports/', views.ConsolePortListView.as_view(), name='consoleport_list'),

View File

@@ -1229,6 +1229,7 @@ class PlatformView(generic.ObjectView):
return {
'devices_table': devices_table,
'virtualmachine_count': VirtualMachine.objects.filter(platform=instance).count()
}

View File

@@ -1,3 +1,4 @@
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from drf_yasg.utils import swagger_serializer_method
@@ -30,6 +31,7 @@ __all__ = (
'ExportTemplateSerializer',
'ImageAttachmentSerializer',
'JobResultSerializer',
'JournalEntrySerializer',
'ObjectChangeSerializer',
'ReportDetailSerializer',
'ReportSerializer',
@@ -192,6 +194,12 @@ class JournalEntrySerializer(ValidatedModelSerializer):
queryset=ContentType.objects.all()
)
assigned_object = serializers.SerializerMethodField(read_only=True)
created_by = serializers.PrimaryKeyRelatedField(
allow_null=True,
queryset=User.objects.all(),
required=False,
default=serializers.CurrentUserDefault()
)
kind = ChoiceField(
choices=JournalEntryKindChoices,
required=False

View File

@@ -2,8 +2,9 @@ from contextlib import contextmanager
from django.db.models.signals import m2m_changed, pre_delete, post_save
from extras.signals import clear_webhooks, _clear_webhook_queue, _handle_changed_object, _handle_deleted_object
from utilities.utils import curry
from extras.signals import clear_webhooks, clear_webhook_queue, handle_changed_object, handle_deleted_object
from netbox import thread_locals
from netbox.request_context import set_request
from .webhooks import flush_webhooks
@@ -15,12 +16,8 @@ def change_logging(request):
:param request: WSGIRequest object with a unique `id` set
"""
webhook_queue = []
# Curry signals receivers to pass the current request
handle_changed_object = curry(_handle_changed_object, request, webhook_queue)
handle_deleted_object = curry(_handle_deleted_object, request, webhook_queue)
clear_webhook_queue = curry(_clear_webhook_queue, webhook_queue)
set_request(request)
thread_locals.webhook_queue = []
# Connect our receivers to the post_save and post_delete signals.
post_save.connect(handle_changed_object, dispatch_uid='handle_changed_object')
@@ -38,5 +35,8 @@ def change_logging(request):
clear_webhooks.disconnect(clear_webhook_queue, dispatch_uid='clear_webhook_queue')
# Flush queued webhooks to RQ
flush_webhooks(webhook_queue)
del webhook_queue
flush_webhooks(thread_locals.webhook_queue)
del thread_locals.webhook_queue
# Clear the request from thread-local storage
set_request(None)

View File

@@ -15,6 +15,7 @@ from .models import *
__all__ = (
'ConfigContextFilterSet',
'ContentTypeFilterSet',
'CustomFieldFilterSet',
'CustomLinkFilterSet',
'ExportTemplateFilterSet',
'ImageAttachmentFilterSet',
@@ -34,6 +35,10 @@ EXACT_FILTER_TYPES = (
class WebhookFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
content_types = ContentTypeFilter()
http_method = django_filters.MultipleChoiceFilter(
choices=WebhookHttpMethodChoices
@@ -46,30 +51,81 @@ class WebhookFilterSet(BaseFilterSet):
'http_method', 'http_content_type', 'secret', 'ssl_verification', 'ca_file_path',
]
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(payload_url__icontains=value)
)
class CustomFieldFilterSet(django_filters.FilterSet):
class CustomFieldFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
content_types = ContentTypeFilter()
class Meta:
model = CustomField
fields = ['id', 'content_types', 'name', 'required', 'filter_logic', 'weight']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(label__icontains=value) |
Q(description__icontains=value)
)
class CustomLinkFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
class Meta:
model = CustomLink
fields = ['id', 'content_type', 'name', 'link_text', 'link_url', 'weight', 'group_name', 'new_window']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(link_text__icontains=value) |
Q(link_url__icontains=value) |
Q(group_name__icontains=value)
)
class ExportTemplateFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
class Meta:
model = ExportTemplate
fields = ['id', 'content_type', 'name']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(description__icontains=value)
)
class ImageAttachmentFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
created = django_filters.DateTimeFilter()
content_type = ContentTypeFilter()
@@ -77,6 +133,11 @@ class ImageAttachmentFilterSet(BaseFilterSet):
model = ImageAttachment
fields = ['id', 'content_type_id', 'object_id', 'name']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(name__icontains=value)
class JournalEntryFilterSet(ChangeLoggedModelFilterSet):
q = django_filters.CharFilter(

View File

@@ -70,7 +70,7 @@ class CustomLinkForm(BootstrapMixin, forms.ModelForm):
class ExportTemplateForm(BootstrapMixin, forms.ModelForm):
content_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_links')
limit_choices_to=FeatureQuery('export_templates')
)
class Meta:

View File

@@ -18,48 +18,60 @@ class Command(BaseCommand):
def handle(self, *args, **options):
# Clear expired authentication sessions (essentially replicating the `clearsessions` command)
self.stdout.write("[*] Clearing expired authentication sessions")
if options['verbosity'] >= 2:
self.stdout.write(f"\tConfigured session engine: {settings.SESSION_ENGINE}")
if options['verbosity']:
self.stdout.write("[*] Clearing expired authentication sessions")
if options['verbosity'] >= 2:
self.stdout.write(f"\tConfigured session engine: {settings.SESSION_ENGINE}")
engine = import_module(settings.SESSION_ENGINE)
try:
engine.SessionStore.clear_expired()
self.stdout.write("\tSessions cleared.", self.style.SUCCESS)
if options['verbosity']:
self.stdout.write("\tSessions cleared.", self.style.SUCCESS)
except NotImplementedError:
self.stdout.write(
f"\tThe configured session engine ({settings.SESSION_ENGINE}) does not support "
f"clearing sessions; skipping."
)
if options['verbosity']:
self.stdout.write(
f"\tThe configured session engine ({settings.SESSION_ENGINE}) does not support "
f"clearing sessions; skipping."
)
# Delete expired ObjectRecords
self.stdout.write("[*] Checking for expired changelog records")
if options['verbosity']:
self.stdout.write("[*] Checking for expired changelog records")
if settings.CHANGELOG_RETENTION:
cutoff = timezone.now() - timedelta(days=settings.CHANGELOG_RETENTION)
if options['verbosity'] >= 2:
self.stdout.write(f"Retention period: {settings.CHANGELOG_RETENTION} days")
self.stdout.write(f"\tRetention period: {settings.CHANGELOG_RETENTION} days")
self.stdout.write(f"\tCut-off time: {cutoff}")
expired_records = ObjectChange.objects.filter(time__lt=cutoff).count()
if expired_records:
self.stdout.write(f"\tDeleting {expired_records} expired records... ", self.style.WARNING, ending="")
self.stdout.flush()
if options['verbosity']:
self.stdout.write(
f"\tDeleting {expired_records} expired records... ",
self.style.WARNING,
ending=""
)
self.stdout.flush()
ObjectChange.objects.filter(time__lt=cutoff)._raw_delete(using=DEFAULT_DB_ALIAS)
self.stdout.write("Done.", self.style.WARNING)
else:
self.stdout.write("\tNo expired records found.")
else:
if options['verbosity']:
self.stdout.write("Done.", self.style.SUCCESS)
elif options['verbosity']:
self.stdout.write("\tNo expired records found.", self.style.SUCCESS)
elif options['verbosity']:
self.stdout.write(
f"\tSkipping: No retention period specified (CHANGELOG_RETENTION = {settings.CHANGELOG_RETENTION})"
)
# Check for new releases (if enabled)
self.stdout.write("[*] Checking for latest release")
if options['verbosity']:
self.stdout.write("[*] Checking for latest release")
if settings.RELEASE_CHECK_URL:
headers = {
'Accept': 'application/vnd.github.v3+json',
}
try:
self.stdout.write(f"\tFetching {settings.RELEASE_CHECK_URL}")
if options['verbosity'] >= 2:
self.stdout.write(f"\tFetching {settings.RELEASE_CHECK_URL}")
response = requests.get(
url=settings.RELEASE_CHECK_URL,
headers=headers,
@@ -73,15 +85,19 @@ class Command(BaseCommand):
continue
releases.append((version.parse(release['tag_name']), release.get('html_url')))
latest_release = max(releases)
self.stdout.write(f"\tFound {len(response.json())} releases; {len(releases)} usable")
self.stdout.write(f"\tLatest release: {latest_release[0]}")
if options['verbosity'] >= 2:
self.stdout.write(f"\tFound {len(response.json())} releases; {len(releases)} usable")
if options['verbosity']:
self.stdout.write(f"\tLatest release: {latest_release[0]}", self.style.SUCCESS)
# Cache the most recent release
cache.set('latest_release', latest_release, None)
except requests.exceptions.RequestException as exc:
self.stdout.write(f"\tRequest error: {exc}")
self.stdout.write(f"\tRequest error: {exc}", self.style.ERROR)
else:
self.stdout.write(f"\tSkipping: RELEASE_CHECK_URL not set")
if options['verbosity']:
self.stdout.write(f"\tSkipping: RELEASE_CHECK_URL not set")
self.stdout.write("Finished.", self.style.SUCCESS)
if options['verbosity']:
self.stdout.write("Finished.", self.style.SUCCESS)

View File

@@ -0,0 +1,158 @@
import json
import logging
import sys
import traceback
import uuid
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from extras.api.serializers import ScriptOutputSerializer
from extras.choices import JobResultStatusChoices
from extras.context_managers import change_logging
from extras.models import JobResult
from extras.scripts import get_script
from utilities.exceptions import AbortTransaction
from utilities.utils import NetBoxFakeRequest
class Command(BaseCommand):
help = "Run a script in Netbox"
def add_arguments(self, parser):
parser.add_argument(
'--loglevel',
help="Logging Level (default: info)",
dest='loglevel',
default='info',
choices=['debug', 'info', 'warning', 'error', 'critical'])
parser.add_argument('--commit', help="Commit this script to database", action='store_true')
parser.add_argument('--user', help="User script is running as")
parser.add_argument('--data', help="Data as a string encapsulated JSON blob")
parser.add_argument('script', help="Script to run")
def handle(self, *args, **options):
def _run_script():
"""
Core script execution task. We capture this within a subfunction to allow for conditionally wrapping it with
the change_logging context manager (which is bypassed if commit == False).
"""
try:
with transaction.atomic():
script.output = script.run(data=data, commit=commit)
job_result.set_status(JobResultStatusChoices.STATUS_COMPLETED)
if not commit:
raise AbortTransaction()
except AbortTransaction:
script.log_info("Database changes have been reverted automatically.")
except Exception as e:
stacktrace = traceback.format_exc()
script.log_failure(
f"An exception occurred: `{type(e).__name__}: {e}`\n```\n{stacktrace}\n```"
)
script.log_info("Database changes have been reverted due to error.")
logger.error(f"Exception raised during script execution: {e}")
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
finally:
job_result.data = ScriptOutputSerializer(script).data
job_result.save()
logger.info(f"Script completed in {job_result.duration}")
# Params
script = options['script']
loglevel = options['loglevel']
commit = options['commit']
try:
data = json.loads(options['data'])
except TypeError:
data = {}
module, name = script.split('.', 1)
# Take user from command line if provided and exists, other
if options['user']:
try:
user = User.objects.get(username=options['user'])
except User.DoesNotExist:
user = User.objects.filter(is_superuser=True).order_by('pk')[0]
else:
user = User.objects.filter(is_superuser=True).order_by('pk')[0]
# Setup logging to Stdout
formatter = logging.Formatter(f'[%(asctime)s][%(levelname)s] - %(message)s')
stdouthandler = logging.StreamHandler(sys.stdout)
stdouthandler.setLevel(logging.DEBUG)
stdouthandler.setFormatter(formatter)
logger = logging.getLogger(f"netbox.scripts.{module}.{name}")
logger.addHandler(stdouthandler)
try:
logger.setLevel({
'critical': logging.CRITICAL,
'debug': logging.DEBUG,
'error': logging.ERROR,
'fatal': logging.FATAL,
'info': logging.INFO,
'warning': logging.WARNING,
}[loglevel])
except KeyError:
raise CommandError(f"Invalid log level: {loglevel}")
# Get the script
script = get_script(module, name)()
# Parse the parameters
form = script.as_form(data, None)
script_content_type = ContentType.objects.get(app_label='extras', model='script')
# Delete any previous terminal state results
JobResult.objects.filter(
obj_type=script_content_type,
name=script.full_name,
status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
).delete()
# Create the job result
job_result = JobResult.objects.create(
name=script.full_name,
obj_type=script_content_type,
user=User.objects.filter(is_superuser=True).order_by('pk')[0],
job_id=uuid.uuid4()
)
request = NetBoxFakeRequest({
'META': {},
'POST': data,
'GET': {},
'FILES': {},
'user': user,
'path': '',
'id': job_result.job_id
})
if form.is_valid():
job_result.status = JobResultStatusChoices.STATUS_RUNNING
job_result.save()
logger.info(f"Running script (commit={commit})")
script.request = request
# Execute the script. If commit is True, wrap it with the change_logging context manager to ensure we process
# change logging, webhooks, etc.
with change_logging(request):
_run_script()
else:
logger.error('Data is not valid:')
for field, errors in form.errors.get_json_data().items():
for error in errors:
logger.error(f'\t{field}: {error.get("message")}')
job_result.status = JobResultStatusChoices.STATUS_ERRORED
job_result.save()

View File

@@ -7,6 +7,7 @@ from django.contrib.postgres.fields import ArrayField
from django.core.validators import RegexValidator, ValidationError
from django.db import models
from django.urls import reverse
from django.utils.html import escape
from django.utils.safestring import mark_safe
from extras.choices import *
@@ -30,7 +31,7 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
return self.get_queryset().filter(content_types=content_type)
@extras_features('webhooks')
@extras_features('webhooks', 'export_templates')
class CustomField(ChangeLoggedModel):
content_types = models.ManyToManyField(
to=ContentType,
@@ -287,7 +288,7 @@ class CustomField(ChangeLoggedModel):
field.model = self
field.label = str(self)
if self.description:
field.help_text = self.description
field.help_text = escape(self.description)
return field

View File

@@ -9,7 +9,7 @@ from django.db import models
from django.http import HttpResponse
from django.urls import reverse
from django.utils import timezone
from django.utils.formats import date_format, time_format
from django.utils.formats import date_format
from rest_framework.utils.encoders import JSONEncoder
from extras.choices import *
@@ -36,7 +36,7 @@ __all__ = (
# Webhooks
#
@extras_features('webhooks')
@extras_features('webhooks', 'export_templates')
class Webhook(ChangeLoggedModel):
"""
A Webhook defines a request that will be sent to a remote application when an object is created, updated, and/or
@@ -175,7 +175,7 @@ class Webhook(ChangeLoggedModel):
# Custom links
#
@extras_features('webhooks')
@extras_features('webhooks', 'export_templates')
class CustomLink(ChangeLoggedModel):
"""
A custom link to an external representation of a NetBox object. The link text and URL fields accept Jinja2 template
@@ -234,7 +234,7 @@ class CustomLink(ChangeLoggedModel):
# Export templates
#
@extras_features('webhooks')
@extras_features('webhooks', 'export_templates')
class ExportTemplate(ChangeLoggedModel):
content_type = models.ForeignKey(
to=ContentType,
@@ -357,6 +357,8 @@ class ImageAttachment(BigIDModel):
objects = RestrictedQuerySet.as_manager()
clone_fields = ('content_type', 'object_id')
class Meta:
ordering = ('name', 'pk') # name may be non-unique

View File

@@ -14,7 +14,7 @@ from utilities.querysets import RestrictedQuerySet
# Tags
#
@extras_features('webhooks')
@extras_features('webhooks', 'export_templates')
class Tag(ChangeLoggedModel, TagBase):
color = ColorField(
default=ColorChoices.COLOR_GREY

View File

@@ -3,8 +3,8 @@ import json
import logging
import os
import pkgutil
import sys
import traceback
import warnings
from collections import OrderedDict
import yaml
@@ -345,9 +345,14 @@ class BaseScript:
"""
Return data from a YAML file
"""
try:
from yaml import CLoader as Loader
except ImportError:
from yaml import Loader
file_path = os.path.join(settings.SCRIPTS_ROOT, filename)
with open(file_path, 'r') as datafile:
data = yaml.load(datafile)
data = yaml.load(datafile, Loader=Loader)
return data
@@ -470,16 +475,22 @@ def get_scripts(use_names=False):
defined name in place of the actual module name.
"""
scripts = OrderedDict()
# Iterate through all modules within the reports path. These are the user-created files in which reports are
# defined.
for importer, module_name, _ in pkgutil.iter_modules([settings.SCRIPTS_ROOT]):
# Remove cached module to ensure consistency with filesystem
if module_name in sys.modules:
del sys.modules[module_name]
module = importer.find_module(module_name).load_module(module_name)
if use_names and hasattr(module, 'name'):
module_name = module.name
module_scripts = OrderedDict()
for name, cls in inspect.getmembers(module, is_script):
module_scripts[name] = cls
script_order = getattr(module, "script_order", ())
ordered_scripts = [cls for cls in script_order if is_script(cls)]
unordered_scripts = [cls for _, cls in inspect.getmembers(module, is_script) if cls not in script_order]
for cls in [*ordered_scripts, *unordered_scripts]:
module_scripts[cls.__name__] = cls
if module_scripts:
scripts[module_name] = module_scripts

View File

@@ -6,6 +6,8 @@ from django.db.models.signals import m2m_changed, post_save, pre_delete
from django.dispatch import receiver, Signal
from django_prometheus.models import model_deletes, model_inserts, model_updates
from netbox import thread_locals
from netbox.request_context import get_request
from netbox.signals import post_clean
from .choices import ObjectChangeActionChoices
from .models import CustomField, ObjectChange
@@ -20,10 +22,16 @@ from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook
clear_webhooks = Signal()
def _handle_changed_object(request, webhook_queue, sender, instance, **kwargs):
def handle_changed_object(sender, instance, **kwargs):
"""
Fires when an object is created or updated.
"""
if not hasattr(instance, 'to_objectchange'):
return
request = get_request()
m2m_changed = False
def is_same_object(instance, webhook_data):
return (
ContentType.objects.get_for_model(instance) == webhook_data['content_type'] and
@@ -31,11 +39,6 @@ def _handle_changed_object(request, webhook_queue, sender, instance, **kwargs):
request.id == webhook_data['request_id']
)
if not hasattr(instance, 'to_objectchange'):
return
m2m_changed = False
# Determine the type of change being made
if kwargs.get('created'):
action = ObjectChangeActionChoices.ACTION_CREATE
@@ -65,6 +68,7 @@ def _handle_changed_object(request, webhook_queue, sender, instance, **kwargs):
objectchange.save()
# If this is an M2M change, update the previously queued webhook (from post_save)
webhook_queue = thread_locals.webhook_queue
if m2m_changed and webhook_queue and is_same_object(instance, webhook_queue[-1]):
instance.refresh_from_db() # Ensure that we're working with fresh M2M assignments
webhook_queue[-1]['data'] = serialize_for_webhook(instance)
@@ -79,13 +83,15 @@ def _handle_changed_object(request, webhook_queue, sender, instance, **kwargs):
model_updates.labels(instance._meta.model_name).inc()
def _handle_deleted_object(request, webhook_queue, sender, instance, **kwargs):
def handle_deleted_object(sender, instance, **kwargs):
"""
Fires when an object is deleted.
"""
if not hasattr(instance, 'to_objectchange'):
return
request = get_request()
# Record an ObjectChange if applicable
if hasattr(instance, 'to_objectchange'):
objectchange = instance.to_objectchange(ObjectChangeActionChoices.ACTION_DELETE)
@@ -94,19 +100,21 @@ def _handle_deleted_object(request, webhook_queue, sender, instance, **kwargs):
objectchange.save()
# Enqueue webhooks
webhook_queue = thread_locals.webhook_queue
enqueue_object(webhook_queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE)
# Increment metric counters
model_deletes.labels(instance._meta.model_name).inc()
def _clear_webhook_queue(webhook_queue, sender, **kwargs):
def clear_webhook_queue(sender, **kwargs):
"""
Delete any queued webhooks (e.g. because of an aborted bulk transaction)
"""
logger = logging.getLogger('webhooks')
logger.info(f"Clearing {len(webhook_queue)} queued webhooks ({sender})")
webhook_queue = thread_locals.webhook_queue
logger.info(f"Clearing {len(webhook_queue)} queued webhooks ({sender})")
webhook_queue.clear()

View File

@@ -57,8 +57,8 @@ class CustomFieldTable(BaseTable):
class Meta(BaseTable.Meta):
model = CustomField
fields = (
'pk', 'name', 'content_types', 'label', 'type', 'required', 'weight', 'default', 'description',
'filter_logic', 'choices',
'pk', 'id', 'name', 'content_types', 'label', 'type', 'required', 'weight', 'default',
'description', 'filter_logic', 'choices',
)
default_columns = ('pk', 'name', 'content_types', 'label', 'type', 'required', 'description')
@@ -78,7 +78,8 @@ class CustomLinkTable(BaseTable):
class Meta(BaseTable.Meta):
model = CustomLink
fields = (
'pk', 'name', 'content_type', 'link_text', 'link_url', 'weight', 'group_name', 'button_class', 'new_window',
'pk', 'id', 'name', 'content_type', 'link_text', 'link_url', 'weight', 'group_name',
'button_class', 'new_window',
)
default_columns = ('pk', 'name', 'content_type', 'group_name', 'button_class', 'new_window')
@@ -98,7 +99,7 @@ class ExportTemplateTable(BaseTable):
class Meta(BaseTable.Meta):
model = ExportTemplate
fields = (
'pk', 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment',
'pk', 'id', 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment',
)
default_columns = (
'pk', 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment',
@@ -132,7 +133,7 @@ class WebhookTable(BaseTable):
class Meta(BaseTable.Meta):
model = Webhook
fields = (
'pk', 'name', 'content_types', 'enabled', 'type_create', 'type_update', 'type_delete', 'http_method',
'pk', 'id', 'name', 'content_types', 'enabled', 'type_create', 'type_update', 'type_delete', 'http_method',
'payload_url', 'secret', 'ssl_validation', 'ca_file_path',
)
default_columns = (
@@ -155,10 +156,16 @@ class TagTable(BaseTable):
class Meta(BaseTable.Meta):
model = Tag
fields = ('pk', 'name', 'items', 'slug', 'color', 'description', 'actions')
fields = ('pk', 'id', 'name', 'items', 'slug', 'color', 'description', 'actions')
default_columns = ('pk', 'name', 'items', 'slug', 'color', 'description', 'actions')
class TaggedItemTable(BaseTable):
id = tables.Column(
verbose_name='ID',
linkify=lambda record: record.content_object.get_absolute_url(),
accessor='content_object__id'
)
content_type = ContentTypeColumn(
verbose_name='Type'
)
@@ -170,7 +177,7 @@ class TaggedItemTable(BaseTable):
class Meta(BaseTable.Meta):
model = TaggedItem
fields = ('content_type', 'content_object')
fields = ('id', 'content_type', 'content_object')
class ConfigContextTable(BaseTable):
@@ -185,8 +192,8 @@ class ConfigContextTable(BaseTable):
class Meta(BaseTable.Meta):
model = ConfigContext
fields = (
'pk', 'name', 'weight', 'is_active', 'description', 'regions', 'sites', 'roles', 'platforms',
'cluster_groups', 'clusters', 'tenant_groups', 'tenants',
'pk', 'id', 'name', 'weight', 'is_active', 'description', 'regions', 'sites', 'roles',
'platforms', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants',
)
default_columns = ('pk', 'name', 'weight', 'is_active', 'description')
@@ -211,7 +218,7 @@ class ObjectChangeTable(BaseTable):
class Meta(BaseTable.Meta):
model = ObjectChange
fields = ('time', 'user_name', 'action', 'changed_object_type', 'object_repr', 'request_id')
fields = ('id', 'time', 'user_name', 'action', 'changed_object_type', 'object_repr', 'request_id')
class ObjectJournalTable(BaseTable):
@@ -232,7 +239,7 @@ class ObjectJournalTable(BaseTable):
class Meta(BaseTable.Meta):
model = JournalEntry
fields = ('created', 'created_by', 'kind', 'comments', 'actions')
fields = ('id', 'created', 'created_by', 'kind', 'comments', 'actions')
class JournalEntryTable(ObjectJournalTable):
@@ -250,5 +257,10 @@ class JournalEntryTable(ObjectJournalTable):
class Meta(BaseTable.Meta):
model = JournalEntry
fields = (
'pk', 'created', 'created_by', 'assigned_object_type', 'assigned_object', 'kind', 'comments', 'actions'
'pk', 'id', 'created', 'created_by', 'assigned_object_type', 'assigned_object', 'kind',
'comments', 'actions'
)
default_columns = (
'pk', 'created', 'created_by', 'assigned_object_type', 'assigned_object', 'kind',
'comments', 'actions'
)

View File

@@ -1,3 +1,5 @@
import tempfile
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase
from netaddr import IPAddress, IPNetwork
@@ -11,6 +13,50 @@ CHOICES = (
('0000ff', 'Blue')
)
YAML_DATA = """
Foo: 123
Bar: 456
Baz:
- A
- B
- C
"""
JSON_DATA = """
{
"Foo": 123,
"Bar": 456,
"Baz": ["A", "B", "C"]
}
"""
class ScriptTest(TestCase):
def test_load_yaml(self):
datafile = tempfile.NamedTemporaryFile()
datafile.write(bytes(YAML_DATA, 'UTF-8'))
datafile.seek(0)
data = Script().load_yaml(datafile.name)
self.assertEqual(data, {
'Foo': 123,
'Bar': 456,
'Baz': ['A', 'B', 'C'],
})
def test_load_json(self):
datafile = tempfile.NamedTemporaryFile()
datafile.write(bytes(JSON_DATA, 'UTF-8'))
datafile.seek(0)
data = Script().load_json(datafile.name)
self.assertEqual(data, {
'Foo': 123,
'Bar': 456,
'Baz': ['A', 'B', 'C'],
})
class ScriptVariablesTest(TestCase):

View File

@@ -78,6 +78,7 @@ urlpatterns = [
kwargs={'model': models.ConfigContext}),
# Image attachments
path('image-attachments/add/', views.ImageAttachmentEditView.as_view(), name='imageattachment_add'),
path('image-attachments/<int:pk>/edit/', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'),
path('image-attachments/<int:pk>/delete/', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'),

View File

@@ -11,7 +11,7 @@ from rq import Worker
from netbox.views import generic
from utilities.forms import ConfirmationForm
from utilities.tables import paginate_table
from utilities.utils import copy_safe_request, count_related, shallow_compare_dict
from utilities.utils import copy_safe_request, count_related, normalize_querydict, shallow_compare_dict
from utilities.views import ContentTypePermissionRequiredMixin
from . import filtersets, forms, tables
from .choices import JobResultStatusChoices
@@ -472,22 +472,22 @@ class ImageAttachmentEditView(generic.ObjectEditView):
queryset = ImageAttachment.objects.all()
model_form = forms.ImageAttachmentForm
def alter_obj(self, imageattachment, request, args, kwargs):
if not imageattachment.pk:
def alter_obj(self, instance, request, args, kwargs):
if not instance.pk:
# Assign the parent object based on URL kwargs
model = kwargs.get('model')
imageattachment.parent = get_object_or_404(model, pk=kwargs['object_id'])
return imageattachment
content_type = get_object_or_404(ContentType, pk=request.GET.get('content_type'))
instance.parent = get_object_or_404(content_type.model_class(), pk=request.GET.get('object_id'))
return instance
def get_return_url(self, request, imageattachment):
return imageattachment.parent.get_absolute_url()
def get_return_url(self, request, obj=None):
return obj.parent.get_absolute_url() if obj else super().get_return_url(request)
class ImageAttachmentDeleteView(generic.ObjectDeleteView):
queryset = ImageAttachment.objects.all()
def get_return_url(self, request, imageattachment):
return imageattachment.parent.get_absolute_url()
def get_return_url(self, request, obj=None):
return obj.parent.get_absolute_url() if obj else super().get_return_url(request)
#
@@ -754,7 +754,7 @@ class ScriptView(ContentTypePermissionRequiredMixin, GetScriptMixin, View):
def get(self, request, module, name):
script = self._get_script(name, module)
form = script.as_form(initial=request.GET)
form = script.as_form(initial=normalize_querydict(request.GET))
# Look for a pending JobResult (use the latest one by creation timestamp)
script_content_type = ContentType.objects.get(app_label='extras', model='script')

View File

@@ -257,11 +257,18 @@ class IPAddressCSVForm(CustomFieldModelCSVForm):
device = self.cleaned_data.get('device')
virtual_machine = self.cleaned_data.get('virtual_machine')
interface = self.cleaned_data.get('interface')
is_primary = self.cleaned_data.get('is_primary')
# Validate is_primary
if is_primary and not device and not virtual_machine:
raise forms.ValidationError("No device or virtual machine specified; cannot set as primary IP")
raise forms.ValidationError({
"is_primary": "No device or virtual machine specified; cannot set as primary IP"
})
if is_primary and not interface:
raise forms.ValidationError({
"is_primary": "No interface specified; cannot set as primary IP"
})
def save(self, *args, **kwargs):

View File

@@ -1,3 +1,4 @@
import django_filters
from django import forms
from django.utils.translation import gettext as _
@@ -409,7 +410,7 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterFo
field_groups = [
['q', 'tag'],
['region_id', 'site_group_id', 'site_id'],
['group_id', 'status', 'role_id'],
['group_id', 'status', 'role_id', 'vid'],
['tenant_group_id', 'tenant_id'],
]
q = forms.CharField(
@@ -461,6 +462,10 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterFo
label=_('Role'),
fetch_trigger='open'
)
vid = forms.IntegerField(
required=False,
label='VLAN ID'
)
tag = TagFilterField(model)

View File

@@ -39,15 +39,7 @@ PREFIXFLAT_LINK = """
{% if record.pk %}
<a href="{% url 'ipam:prefix' pk=record.pk %}">{{ record.prefix }}</a>
{% else %}
&mdash;
{% endif %}
"""
PREFIX_ROLE_LINK = """
{% if record.role %}
<a href="{% url 'ipam:prefix_list' %}?role={{ record.role.slug }}">{{ record.role }}</a>
{% else %}
&mdash;
{{ record.prefix }}
{% endif %}
"""
@@ -97,7 +89,7 @@ class RIRTable(BaseTable):
class Meta(BaseTable.Meta):
model = RIR
fields = ('pk', 'name', 'slug', 'is_private', 'aggregate_count', 'description', 'actions')
fields = ('pk', 'id', 'name', 'slug', 'is_private', 'aggregate_count', 'description', 'actions')
default_columns = ('pk', 'name', 'is_private', 'aggregate_count', 'description', 'actions')
@@ -129,7 +121,7 @@ class AggregateTable(BaseTable):
class Meta(BaseTable.Meta):
model = Aggregate
fields = ('pk', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description', 'tags')
fields = ('pk', 'id', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description', 'tags')
default_columns = ('pk', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description')
@@ -156,7 +148,7 @@ class RoleTable(BaseTable):
class Meta(BaseTable.Meta):
model = Role
fields = ('pk', 'name', 'slug', 'prefix_count', 'vlan_count', 'description', 'weight', 'actions')
fields = ('pk', 'id', 'name', 'slug', 'prefix_count', 'vlan_count', 'description', 'weight', 'actions')
default_columns = ('pk', 'name', 'prefix_count', 'vlan_count', 'description', 'actions')
@@ -214,12 +206,17 @@ class PrefixTable(BaseTable):
site = tables.Column(
linkify=True
)
vlan_group = tables.Column(
accessor='vlan__group',
linkify=True,
verbose_name='VLAN Group'
)
vlan = tables.Column(
linkify=True,
verbose_name='VLAN'
)
role = tables.TemplateColumn(
template_code=PREFIX_ROLE_LINK
role = tables.Column(
linkify=True
)
is_pool = BooleanColumn(
verbose_name='Pool'
@@ -238,8 +235,8 @@ class PrefixTable(BaseTable):
class Meta(BaseTable.Meta):
model = Prefix
fields = (
'pk', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role',
'is_pool', 'mark_utilized', 'description', 'tags',
'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan_group',
'vlan', 'role', 'is_pool', 'mark_utilized', 'description', 'tags',
)
default_columns = (
'pk', 'prefix', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'description',
@@ -264,15 +261,23 @@ class IPRangeTable(BaseTable):
status = ChoiceFieldColumn(
default=AVAILABLE_LABEL
)
role = tables.TemplateColumn(
template_code=PREFIX_ROLE_LINK
role = tables.Column(
linkify=True
)
tenant = TenantColumn()
utilization = UtilizationColumn(
accessor='utilization',
orderable=False
)
tags = TagColumn(
url_name='ipam:iprange_list'
)
class Meta(BaseTable.Meta):
model = IPRange
fields = (
'pk', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description',
'pk', 'id', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description',
'utilization', 'tags',
)
default_columns = (
'pk', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description',
@@ -318,7 +323,7 @@ class IPAddressTable(BaseTable):
verbose_name='NAT (Inside)'
)
assigned = BooleanColumn(
accessor='assigned_object',
accessor='assigned_object_id',
linkify=True,
verbose_name='Assigned'
)
@@ -329,7 +334,7 @@ class IPAddressTable(BaseTable):
class Meta(BaseTable.Meta):
model = IPAddress
fields = (
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'assigned', 'dns_name', 'description',
'pk', 'id', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'assigned', 'dns_name', 'description',
'tags',
)
default_columns = (
@@ -353,6 +358,7 @@ class IPAddressAssignTable(BaseTable):
class Meta(BaseTable.Meta):
model = IPAddress
fields = ('address', 'dns_name', 'vrf', 'status', 'role', 'tenant', 'assigned_object', 'description')
exclude = ('id', )
orderable = False
@@ -377,3 +383,4 @@ class InterfaceIPAddressTable(BaseTable):
class Meta(BaseTable.Meta):
model = IPAddress
fields = ('address', 'vrf', 'status', 'role', 'tenant', 'description')
exclude = ('id', )

View File

@@ -31,5 +31,5 @@ class ServiceTable(BaseTable):
class Meta(BaseTable.Meta):
model = Service
fields = ('pk', 'name', 'parent', 'protocol', 'ports', 'ipaddresses', 'description', 'tags')
fields = ('pk', 'id', 'name', 'parent', 'protocol', 'ports', 'ipaddresses', 'description', 'tags')
default_columns = ('pk', 'name', 'parent', 'protocol', 'ports', 'description')

View File

@@ -6,7 +6,7 @@ from dcim.models import Interface
from tenancy.tables import TenantColumn
from utilities.tables import (
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ContentTypeColumn, LinkedCountColumn, TagColumn,
ToggleColumn,
TemplateColumn, ToggleColumn,
)
from virtualization.models import VMInterface
from ipam.models import *
@@ -35,19 +35,9 @@ VLAN_LINK = """
VLAN_PREFIXES = """
{% for prefix in record.prefixes.all %}
<a href="{% url 'ipam:prefix' pk=prefix.pk %}">{{ prefix }}</a>{% if not forloop.last %}<br />{% endif %}
{% empty %}
&mdash;
{% endfor %}
"""
VLAN_ROLE_LINK = """
{% if record.role %}
<a href="{% url 'ipam:vlan_list' %}?role={{ record.role.slug }}">{{ record.role }}</a>
{% else %}
&mdash;
{% endif %}
"""
VLANGROUP_ADD_VLAN = """
{% with next_vid=record.get_next_available_vid %}
{% if next_vid and perms.ipam.add_vlan %}
@@ -91,7 +81,7 @@ class VLANGroupTable(BaseTable):
class Meta(BaseTable.Meta):
model = VLANGroup
fields = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'slug', 'description', 'actions')
fields = ('pk', 'id', 'name', 'scope_type', 'scope', 'vlan_count', 'slug', 'description', 'actions')
default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'description', 'actions')
@@ -103,7 +93,10 @@ class VLANTable(BaseTable):
pk = ToggleColumn()
vid = tables.TemplateColumn(
template_code=VLAN_LINK,
verbose_name='ID'
verbose_name='VID'
)
name = tables.Column(
linkify=True
)
site = tables.Column(
linkify=True
@@ -115,10 +108,10 @@ class VLANTable(BaseTable):
status = ChoiceFieldColumn(
default=AVAILABLE_LABEL
)
role = tables.TemplateColumn(
template_code=VLAN_ROLE_LINK
role = tables.Column(
linkify=True
)
prefixes = tables.TemplateColumn(
prefixes = TemplateColumn(
template_code=VLAN_PREFIXES,
orderable=False,
verbose_name='Prefixes'
@@ -129,7 +122,7 @@ class VLANTable(BaseTable):
class Meta(BaseTable.Meta):
model = VLAN
fields = ('pk', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description', 'tags')
fields = ('pk', 'id', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description', 'tags')
default_columns = ('pk', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description')
row_attrs = {
'class': lambda record: 'success' if not isinstance(record, VLAN) else '',
@@ -159,6 +152,7 @@ class VLANDevicesTable(VLANMembersTable):
class Meta(BaseTable.Meta):
model = Interface
fields = ('device', 'name', 'tagged', 'actions')
exclude = ('id', )
class VLANVirtualMachinesTable(VLANMembersTable):
@@ -170,6 +164,7 @@ class VLANVirtualMachinesTable(VLANMembersTable):
class Meta(BaseTable.Meta):
model = VMInterface
fields = ('virtual_machine', 'name', 'tagged', 'actions')
exclude = ('id', )
class InterfaceVLANTable(BaseTable):
@@ -190,13 +185,14 @@ class InterfaceVLANTable(BaseTable):
)
tenant = TenantColumn()
status = ChoiceFieldColumn()
role = tables.TemplateColumn(
template_code=VLAN_ROLE_LINK
role = tables.Column(
linkify=True
)
class Meta(BaseTable.Meta):
model = VLAN
fields = ('vid', 'tagged', 'site', 'group', 'name', 'tenant', 'status', 'role', 'description')
exclude = ('id', )
def __init__(self, interface, *args, **kwargs):
self.interface = interface

View File

@@ -1,7 +1,7 @@
import django_tables2 as tables
from tenancy.tables import TenantColumn
from utilities.tables import BaseTable, BooleanColumn, TagColumn, ToggleColumn
from utilities.tables import BaseTable, BooleanColumn, TagColumn, TemplateColumn, ToggleColumn
from ipam.models import *
__all__ = (
@@ -11,9 +11,7 @@ __all__ = (
VRF_TARGETS = """
{% for rt in value.all %}
<a href="{{ rt.get_absolute_url }}">{{ rt }}</a>{% if not forloop.last %}<br />{% endif %}
{% empty %}
&mdash;
<a href="{{ rt.get_absolute_url }}">{{ rt }}</a>{% if not forloop.last %}<br />{% endif %}
{% endfor %}
"""
@@ -34,11 +32,11 @@ class VRFTable(BaseTable):
enforce_unique = BooleanColumn(
verbose_name='Unique'
)
import_targets = tables.TemplateColumn(
import_targets = TemplateColumn(
template_code=VRF_TARGETS,
orderable=False
)
export_targets = tables.TemplateColumn(
export_targets = TemplateColumn(
template_code=VRF_TARGETS,
orderable=False
)
@@ -49,7 +47,7 @@ class VRFTable(BaseTable):
class Meta(BaseTable.Meta):
model = VRF
fields = (
'pk', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', 'export_targets', 'tags',
'pk', 'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', 'export_targets', 'tags',
)
default_columns = ('pk', 'name', 'rd', 'tenant', 'description')
@@ -70,5 +68,5 @@ class RouteTargetTable(BaseTable):
class Meta(BaseTable.Meta):
model = RouteTarget
fields = ('pk', 'name', 'tenant', 'description', 'tags')
fields = ('pk', 'id', 'name', 'tenant', 'description', 'tags')
default_columns = ('pk', 'name', 'tenant', 'description')

View File

@@ -240,6 +240,7 @@ class AggregateView(generic.ObjectView):
return {
'prefix_table': prefix_table,
'permissions': permissions,
'bulk_querystring': f'within={instance.prefix}',
'show_available': request.GET.get('show_available', 'true') == 'true',
}

View File

@@ -0,0 +1,3 @@
import threading
thread_locals = threading.local()

View File

@@ -29,10 +29,13 @@ class TokenAuthentication(authentication.TokenAuthentication):
if settings.REMOTE_AUTH_BACKEND == 'netbox.authentication.LDAPBackend':
from netbox.authentication import LDAPBackend
ldap_backend = LDAPBackend()
user = ldap_backend.populate_user(token.user.username)
# If the user is found in the LDAP directory use it, if not fallback to the local user
if user:
return user, token
# Load from LDAP if FIND_GROUP_PERMS is active
if ldap_backend.settings.FIND_GROUP_PERMS:
user = ldap_backend.populate_user(token.user.username)
# If the user is found in the LDAP directory use it, if not fallback to the local user
if user:
return user, token
return token.user, token

View File

@@ -230,7 +230,7 @@ class ModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ModelViewSet_):
Overrides ListModelMixin to allow processing ExportTemplates.
"""
if 'export' in request.GET:
content_type = ContentType.objects.get_for_model(self.serializer_class.Meta.model)
content_type = ContentType.objects.get_for_model(self.get_serializer_class().Meta.model)
et = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export'])
queryset = self.filter_queryset(self.get_queryset())
return et.render_to_response(queryset)

View File

@@ -34,7 +34,7 @@ class ObjectPermissionMixin():
object_permissions = ObjectPermission.objects.filter(
self.get_permission_filter(user_obj),
enabled=True
).prefetch_related('object_types')
).order_by('id').distinct('id').prefetch_related('object_types')
# Create a dictionary mapping permissions to their constraints
perms = defaultdict(list)

View File

@@ -69,7 +69,13 @@ SEARCH_TYPES = OrderedDict((
}),
('location', {
'queryset': Location.objects.add_related_count(
Location.objects.all(),
Location.objects.add_related_count(
Location.objects.all(),
Device,
'location',
'device_count',
cumulative=True
),
Rack,
'location',
'rack_count',

View File

@@ -1,10 +1,10 @@
import logging
import uuid
from urllib import parse
import logging
from django.conf import settings
from django.contrib.auth.middleware import RemoteUserMiddleware as RemoteUserMiddleware_
from django.contrib import auth
from django.contrib.auth.middleware import RemoteUserMiddleware as RemoteUserMiddleware_
from django.core.exceptions import ImproperlyConfigured
from django.db import ProgrammingError
from django.http import Http404, HttpResponseRedirect
@@ -114,7 +114,7 @@ class RemoteUserMiddleware(RemoteUserMiddleware_):
return groups
class ObjectChangeMiddleware(object):
class ObjectChangeMiddleware:
"""
This middleware performs three functions in response to an object being created, updated, or deleted:

View File

@@ -40,11 +40,6 @@ class ChangeLoggingMixin(models.Model):
blank=True,
null=True
)
object_changes = GenericRelation(
to='extras.ObjectChange',
content_type_field='changed_object_type',
object_id_field='changed_object_id'
)
class Meta:
abstract = True

View File

@@ -0,0 +1,9 @@
from netbox import thread_locals
def set_request(request):
thread_locals.request = request
def get_request():
return getattr(thread_locals, 'request', None)

View File

@@ -4,6 +4,7 @@ import os
import platform
import re
import socket
import sys
import warnings
from urllib.parse import urlsplit
@@ -16,7 +17,7 @@ from django.core.validators import URLValidator
# Environment setup
#
VERSION = '3.0.4'
VERSION = '3.0.12'
# Hostname
HOSTNAME = platform.node()
@@ -25,7 +26,7 @@ HOSTNAME = platform.node()
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Validate Python version
if platform.python_version_tuple() < ('3', '7'):
if sys.version_info < (3, 7):
raise RuntimeError(
f"NetBox requires Python 3.7 or higher (current: Python {platform.python_version()})"
)

View File

@@ -17,7 +17,7 @@ from .admin import admin_site
openapi_info = openapi.Info(
title="NetBox API",
default_version='v2',
default_version='v3',
description="API to access NetBox",
terms_of_service="https://github.com/netbox-community/netbox",
license=openapi.License(name="Apache v2 License"),
@@ -59,9 +59,9 @@ _patterns = [
path('api/users/', include('users.api.urls')),
path('api/virtualization/', include('virtualization.api.urls')),
path('api/status/', StatusView.as_view(), name='api-status'),
path('api/docs/', schema_view.with_ui('swagger'), name='api_docs'),
path('api/redoc/', schema_view.with_ui('redoc'), name='api_redocs'),
re_path(r'^api/swagger(?P<format>.json|.yaml)$', schema_view.without_ui(), name='schema_swagger'),
path('api/docs/', schema_view.with_ui('swagger', cache_timeout=86400), name='api_docs'),
path('api/redoc/', schema_view.with_ui('redoc', cache_timeout=86400), name='api_redocs'),
re_path(r'^api/swagger(?P<format>.json|.yaml)$', schema_view.without_ui(cache_timeout=86400), name='schema_swagger'),
# GraphQL
path('graphql/', csrf_exempt(GraphQLView.as_view(graphiql=True, schema=schema)), name='graphql'),

View File

@@ -137,7 +137,7 @@ class HomeView(View):
release_version, release_url = latest_release
if release_version > version.parse(settings.VERSION):
new_release = {
'version': str(latest_release),
'version': str(release_version),
'url': release_url,
}

View File

@@ -93,6 +93,13 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
def get_required_permission(self):
return get_permission_for_model(self.queryset.model, 'view')
def get_table(self, request, permissions):
table = self.table(self.queryset, user=request.user)
if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']):
table.columns.show('pk')
return table
def export_yaml(self):
"""
Export the queryset of objects as concatenated YAML documents.
@@ -123,8 +130,20 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
filename=f'netbox_{self.queryset.model._meta.verbose_name_plural}.csv'
)
def get(self, request):
def export_template(self, template, request):
"""
Render an ExportTemplate using the current queryset.
:param template: ExportTemplate instance
:param request: The current request
"""
try:
return template.render_to_response(self.queryset)
except Exception as e:
messages.error(request, f"There was an error rendering the selected export template ({template.name}): {e}")
return redirect(request.path)
def get(self, request):
model = self.queryset.model
content_type = ContentType.objects.get_for_model(model)
@@ -137,42 +156,33 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
perm_name = get_permission_for_model(model, action)
permissions[action] = request.user.has_perm(perm_name)
# Export template/YAML rendering
if 'export' in request.GET and request.GET['export'] != 'table':
if 'export' in request.GET:
# An export template has been specified
if request.GET['export']:
et = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export'])
try:
return et.render_to_response(self.queryset)
except Exception as e:
messages.error(
request,
"There was an error rendering the selected export template ({}): {}".format(
et.name, e
)
)
# Export the current table view
if request.GET['export'] == 'table':
table = self.get_table(request, permissions)
columns = [name for name, _ in table.selected_columns]
return self.export_table(table, columns)
# Check for YAML export support
# Render an ExportTemplate
elif request.GET['export']:
template = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export'])
return self.export_template(template, request)
# Check for YAML export support on the model
elif hasattr(model, 'to_yaml'):
response = HttpResponse(self.export_yaml(), content_type='text/yaml')
filename = 'netbox_{}.yaml'.format(self.queryset.model._meta.verbose_name_plural)
response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
return response
# Construct the objects table
table = self.table(self.queryset, user=request.user)
if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']):
table.columns.show('pk')
# Fall back to default table/YAML export
else:
table = self.get_table(request, permissions)
return self.export_table(table)
# Handle table-based exports (current view or static CSV-based)
if request.GET.get('export') == 'table':
columns = [name for name, _ in table.selected_columns]
return self.export_table(table, columns)
elif 'export' in request.GET:
return self.export_table(table)
# Paginate the objects table
# Render the objects table
table = self.get_table(request, permissions)
paginate_table(table, request)
context = {
@@ -283,13 +293,10 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
if '_addanother' in request.POST:
redirect_url = request.path
return_url = request.GET.get('return_url')
if return_url is not None and is_safe_url(url=return_url, allowed_hosts=request.get_host()):
redirect_url = f'{redirect_url}?return_url={return_url}'
# If the object has clone_fields, pre-populate a new instance of the form
if hasattr(obj, 'clone_fields'):
redirect_url += f"{'&' if return_url else '?'}{prepare_cloned_fields(obj)}"
redirect_url += f"?{prepare_cloned_fields(obj)}"
return redirect(redirect_url)
@@ -780,8 +787,21 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
else:
pk_list = request.POST.getlist('pk')
# Include the PK list as initial data for the form
initial_data = {'pk': pk_list}
# Check for other contextual data needed for the form. We avoid passing all of request.GET because the
# filter values will conflict with the bulk edit form fields.
# TODO: Find a better way to accomplish this
if 'device' in request.GET:
initial_data['device'] = request.GET.get('device')
elif 'device_type' in request.GET:
initial_data['device_type'] = request.GET.get('device_type')
elif 'virtual_machine' in request.GET:
initial_data['virtual_machine'] = request.GET.get('virtual_machine')
if '_apply' in request.POST:
form = self.form(model, request.POST)
form = self.form(model, request.POST, initial=initial_data)
restrict_form_fields(form, request.user)
if form.is_valid():
@@ -870,16 +890,6 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
logger.debug("Form validation failed")
else:
# Include the PK list as initial data for the form
initial_data = {'pk': pk_list}
# Check for other contextual data needed for the form. We avoid passing all of request.GET because the
# filter values will conflict with the bulk edit form fields.
# TODO: Find a better way to accomplish this
if 'device' in request.GET:
initial_data['device'] = request.GET.get('device')
elif 'device_type' in request.GET:
initial_data['device_type'] = request.GET.get('device_type')
form = self.form(model, initial=initial_data)
restrict_form_fields(form, request.user)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -36,7 +36,7 @@ function handleSelectAllToggle(event: Event): void {
if (table !== null) {
for (const element of table.querySelectorAll<HTMLInputElement>(
'input[type="checkbox"][name="pk"]',
'tr:not(.d-none) input[type="checkbox"][name="pk"]',
)) {
if (tableSelectAll.checked) {
// Check all PK checkboxes if the select all checkbox is checked.

View File

@@ -1,6 +1,17 @@
import { createToast } from '../bs';
import { getNetboxData, apiGetBase, hasError, isTruthy, toggleLoader } from '../util';
// Match an interface name that begins with a capital letter and is followed by at least one other
// alphabetic character, and ends with a forward-slash-separated numeric sequence such as 0/1/2.
const CISCO_IOS_PATTERN = new RegExp(/^([A-Z][A-Za-z]+)[^0-9]*([0-9/]+)$/);
// Mapping of overrides to default Cisco IOS interface alias behavior (default behavior is to use
// the first two characters).
const CISCO_IOS_OVERRIDES = new Map<string, string>([
// Cisco IOS abbreviates 25G (TwentyFiveGigE) interfaces as 'Twe'.
['TwentyFiveGigE', 'Twe'],
]);
/**
* Get an attribute from a row's cell.
*
@@ -12,6 +23,40 @@ function getData(row: HTMLTableRowElement, query: string, attr: string): string
return row.querySelector(query)?.getAttribute(attr) ?? null;
}
/**
* Get preconfigured alias for given interface. Primarily for matching long-form Cisco IOS
* interface names with short-form Cisco IOS interface names. For example, `GigabitEthernet0/1/2`
* would become `Gi0/1/2`.
*
* This should probably be replaced with something in the primary application (Django), such as
* a database field attached to given interface types. However, this is a temporary measure to
* replace the functionality of this one-liner:
*
* @see https://github.com/netbox-community/netbox/blob/9cc4992fad2fe04ef0211d998c517414e8871d8c/netbox/templates/dcim/device/lldp_neighbors.html#L69
*
* @param name Long-form/original interface name.
*/
function getInterfaceAlias(name: string | null): string | null {
if (name === null) {
return name;
}
if (name.match(CISCO_IOS_PATTERN)) {
// Extract the base name and numeric portions of the interface. For example, an input interface
// of `GigabitEthernet0/0/1` would result in an array of `['GigabitEthernet', '0/0/1']`.
const [base, numeric] = (name.match(CISCO_IOS_PATTERN) ?? []).slice(1, 3);
if (isTruthy(base) && isTruthy(numeric)) {
// Check the override map and use its value if the base name is present in the map.
// Otherwise, use the first two characters of the base name. For example,
// `GigabitEthernet0/0/1` would become `Gi0/0/1`, but `TwentyFiveGigE0/0/1` would become
// `Twe0/0/1`.
const aliasBase = CISCO_IOS_OVERRIDES.get(base) || base.slice(0, 2);
return `${aliasBase}${numeric}`;
}
}
return name;
}
/**
* Update row styles based on LLDP neighbor data.
*/
@@ -23,38 +68,41 @@ function updateRowStyle(data: LLDPNeighborDetail) {
if (row !== null) {
for (const neighbor of neighbors) {
const cellDevice = row.querySelector<HTMLTableCellElement>('td.device');
const cellInterface = row.querySelector<HTMLTableCellElement>('td.interface');
const cDevice = getData(row, 'td.configured_device', 'data');
const cChassis = getData(row, 'td.configured_chassis', 'data-chassis');
const cInterface = getData(row, 'td.configured_interface', 'data');
const deviceCell = row.querySelector<HTMLTableCellElement>('td.device');
const interfaceCell = row.querySelector<HTMLTableCellElement>('td.interface');
const configuredDevice = getData(row, 'td.configured_device', 'data');
const configuredChassis = getData(row, 'td.configured_chassis', 'data-chassis');
const configuredIface = getData(row, 'td.configured_interface', 'data');
let cInterfaceShort = null;
if (isTruthy(cInterface)) {
cInterfaceShort = cInterface.replace(/^([A-Z][a-z])[^0-9]*([0-9/]+)$/, '$1$2');
const interfaceAlias = getInterfaceAlias(configuredIface);
const remoteName = neighbor.remote_system_name ?? '';
const remotePort = neighbor.remote_port ?? '';
const [neighborDevice] = remoteName.split('.');
const [neighborIface] = remotePort.split('.');
if (deviceCell !== null) {
deviceCell.innerText = neighborDevice;
}
const nHost = neighbor.remote_system_name ?? '';
const nPort = neighbor.remote_port ?? '';
const [nDevice] = nHost.split('.');
const [nInterface] = nPort.split('.');
if (cellDevice !== null) {
cellDevice.innerText = nDevice;
if (interfaceCell !== null) {
interfaceCell.innerText = neighborIface;
}
if (cellInterface !== null) {
cellInterface.innerText = nInterface;
}
// Interface has an LLDP neighbor, but the neighbor is not configured in NetBox.
const nonConfiguredDevice = !isTruthy(configuredDevice) && isTruthy(neighborDevice);
if (!isTruthy(cDevice) && isTruthy(nDevice)) {
// NetBox device or chassis matches LLDP neighbor.
const validNode =
configuredDevice === neighborDevice || configuredChassis === neighborDevice;
// NetBox configured interface matches LLDP neighbor interface.
const validInterface =
configuredIface === neighborIface || interfaceAlias === neighborIface;
if (nonConfiguredDevice) {
row.classList.add('info');
} else if (
(cDevice === nDevice || cChassis === nDevice) &&
cInterfaceShort === nInterface
) {
row.classList.add('success');
} else if (cDevice === nDevice || cChassis === nDevice) {
} else if (validNode && validInterface) {
row.classList.add('success');
} else {
row.classList.add('danger');

View File

@@ -1,4 +1,32 @@
import { getElements, scrollTo } from '../util';
import { getElements, scrollTo, isTruthy } from '../util';
/**
* When editing an object, it is sometimes desirable to customize the form action *without*
* overriding the form's `submit` event. For example, the 'Save & Continue' button. We don't want
* to use the `formaction` attribute on that element because it will be included on the form even
* if the button isn't clicked.
*
* @example
* ```html
* <button type="button" return-url="/special-url/">
* Save & Continue
* </button>
* ```
*
* @param event Click event.
*/
function handleSubmitWithReturnUrl(event: MouseEvent): void {
const element = event.target as HTMLElement;
if (element.tagName === 'BUTTON') {
const button = element as HTMLButtonElement;
const action = button.getAttribute('return-url');
const form = button.form;
if (form !== null && isTruthy(action)) {
form.action = action;
form.submit();
}
}
}
function handleFormSubmit(event: Event, form: HTMLFormElement): void {
// Track the names of each invalid field.
@@ -38,6 +66,15 @@ function handleFormSubmit(event: Event, form: HTMLFormElement): void {
}
}
/**
* Attach event listeners to form buttons with the `return-url` attribute present.
*/
function initReturnUrlSubmitButtons(): void {
for (const button of getElements<HTMLButtonElement>('button[return-url]')) {
button.addEventListener('click', handleSubmitWithReturnUrl);
}
}
/**
* Attach an event listener to each form's submitter (button[type=submit]). When called, the
* callback checks the validity of each form field and adds the appropriate Bootstrap CSS class
@@ -54,4 +91,5 @@ export function initFormElements(): void {
submitter.addEventListener('click', (event: Event) => handleFormSubmit(event, form));
}
}
initReturnUrlSubmitButtons();
}

View File

@@ -1,92 +1,90 @@
import { rackImagesState } from './stores';
import { rackImagesState, RackViewSelection } from './stores';
import { getElements } from './util';
import type { StateManager } from './state';
type RackToggleState = { hidden: boolean };
export type RackViewState = { view: RackViewSelection };
/**
* Toggle the Rack Image button to reflect the current state. If the current state is hidden and
* the images are therefore hidden, the button should say "Show Images". Likewise, if the current
* state is *not* hidden, and therefore the images are shown, the button should say "Hide Images".
*
* @param hidden Current State - `true` if images are hidden, `false` otherwise.
* @param button Button element.
* Show or hide images and labels to build the desired rack view.
*/
function toggleRackImagesButton(hidden: boolean, button: HTMLButtonElement): void {
const text = hidden ? 'Show Images' : 'Hide Images';
const selected = hidden ? '' : 'selected';
button.setAttribute('selected', selected);
button.innerHTML = `<i class="mdi mdi-file-image-outline"></i>&nbsp;${text}`;
}
/**
* Show all rack images.
*/
function showRackImages(): void {
for (const elevation of getElements<HTMLObjectElement>('.rack_elevation')) {
const images = elevation.contentDocument?.querySelectorAll('image.device-image') ?? [];
for (const image of images) {
image.classList.remove('hidden');
}
}
}
/**
* Hide all rack images.
*/
function hideRackImages(): void {
for (const elevation of getElements<HTMLObjectElement>('.rack_elevation')) {
const images = elevation.contentDocument?.querySelectorAll('image.device-image') ?? [];
for (const image of images) {
image.classList.add('hidden');
}
}
}
/**
* Toggle the visibility of device images and update the toggle button style.
*/
function handleRackImageToggle(
target: HTMLButtonElement,
state: StateManager<RackToggleState>,
function setRackView(
view: RackViewSelection,
elevation: HTMLObjectElement,
): void {
const initiallyHidden = state.get('hidden');
state.set('hidden', !initiallyHidden);
const hidden = state.get('hidden');
if (hidden) {
hideRackImages();
} else {
showRackImages();
switch(view) {
case 'images-and-labels': {
showRackElements('image.device-image', elevation);
showRackElements('text.device-image-label', elevation);
break;
}
case 'images-only': {
showRackElements('image.device-image', elevation);
hideRackElements('text.device-image-label', elevation);
break;
}
case 'labels-only': {
hideRackElements('image.device-image', elevation);
hideRackElements('text.device-image-label', elevation);
break;
}
}
}
function showRackElements(
selector: string,
elevation: HTMLObjectElement,
): void {
const elements = elevation.contentDocument?.querySelectorAll(selector) ?? [];
for (const element of elements) {
element.classList.remove('hidden');
}
}
function hideRackElements(
selector: string,
elevation: HTMLObjectElement,
): void {
const elements = elevation.contentDocument?.querySelectorAll(selector) ?? [];
for (const element of elements) {
element.classList.add('hidden');
}
toggleRackImagesButton(hidden, target);
}
/**
* Add onClick callback for toggling rack elevation images. Synchronize the image toggle button
* text and display state of images with the local state.
* Change the visibility of all racks in response to selection.
*/
function handleRackViewSelect(
newView: RackViewSelection,
state: StateManager<RackViewState>,
): void {
state.set('view', newView);
for (const elevation of getElements<HTMLObjectElement>('.rack_elevation')) {
setRackView(newView, elevation);
}
}
/**
* Add change callback for selecting rack elevation images, and set
* initial state of select and the images themselves
*/
export function initRackElevation(): void {
const initiallyHidden = rackImagesState.get('hidden');
for (const button of getElements<HTMLButtonElement>('button.toggle-images')) {
toggleRackImagesButton(initiallyHidden, button);
const initialView = rackImagesState.get('view');
button.addEventListener(
'click',
for (const control of getElements<HTMLSelectElement>('select.rack-view')) {
control.selectedIndex = [...control.options].findIndex(o => o.value == initialView);
control.addEventListener(
'change',
event => {
handleRackImageToggle(event.currentTarget as HTMLButtonElement, rackImagesState);
handleRackViewSelect((event.currentTarget as any).value as RackViewSelection, rackImagesState);
},
false,
);
}
for (const element of getElements<HTMLObjectElement>('.rack_elevation')) {
element.addEventListener('load', () => {
if (initiallyHidden) {
hideRackImages();
} else if (!initiallyHidden) {
showRackImages();
}
setRackView(initialView, element);
});
}
}

View File

@@ -107,6 +107,9 @@ function initTableFilter(): void {
// Create a regex pattern from the input search text to match against.
const filter = new RegExp(target.value.toLowerCase().trim());
// List of which rows which match the query
const matchedRows: Array<HTMLTableRowElement> = [];
for (const row of rows) {
// Find the row's checkbox and deselect it, so that it is not accidentally included in form
// submissions.
@@ -114,19 +117,26 @@ function initTableFilter(): void {
if (checkBox !== null) {
checkBox.checked = false;
}
// Iterate through each row's cell values
for (const value of getRowValues(row)) {
if (filter.test(value.toLowerCase())) {
// If this row matches the search pattern, but is already hidden, unhide it and stop
// iterating through the rest of the cells.
row.classList.remove('d-none');
// If this row matches the search pattern, add it to the list.
matchedRows.push(row);
break;
} else {
// If none of the cells in this row match the search pattern, hide the row.
row.classList.add('d-none');
}
}
}
// Iterate the rows again to set visibility.
// This results in a single reflow instead of one for each row.
for (const row of rows) {
if (matchedRows.indexOf(row) >= 0) {
row.classList.remove('d-none');
} else {
row.classList.add('d-none');
}
}
}
input.addEventListener('keyup', debounce(handleInput, 300));
}

View File

@@ -266,10 +266,8 @@ class SideNav {
for (const link of this.getActiveLinks()) {
this.activateLink(link, 'collapse');
}
setTimeout(() => {
this.bodyRemove('hide');
this.bodyAdd('hidden');
}, 300);
this.bodyRemove('hide');
this.bodyAdd('hidden');
}
}

View File

@@ -1,6 +1,8 @@
import { createState } from '../state';
export const rackImagesState = createState<{ hidden: boolean }>(
{ hidden: false },
export type RackViewSelection = 'images-and-labels' | 'images-only' | 'labels-only';
export const rackImagesState = createState<{ view: RackViewSelection }>(
{ view: 'images-and-labels' },
{ persist: true },
);

View File

@@ -73,16 +73,6 @@
color: color-contrast($value);
}
}
// Use proper foreground color in the alert body. Note: this is applied to p, & small because
// we *don't* want to override the h1-h6 colors for alerts, since those are set to a color
// similar to the alert color.
.alert.alert-#{$color} {
p,
small {
color: color-contrast($value);
}
}
}
// Ensure progress bars (utilization graph) in tables aren't too narrow to display the percentage.
@@ -200,16 +190,27 @@ div#advanced-search-content div.card div.card-body div.col:not(:last-child) {
}
table {
a {
text-decoration: none;
&:hover {
text-decoration: underline;
td {
a {
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.dropdown {
// Presence of 'overflow: scroll' on a table causes dropdowns to be improperly hidden when
// opened. See: https://github.com/twbs/bootstrap/issues/24251
position: static;
}
}
&.table > :not(caption) > * > * {
padding-right: $table-cell-padding-x-sm !important;
padding-left: $table-cell-padding-x-sm !important;
th {
a,
a:hover {
color: $body-color;
text-decoration: none;
}
}
td,
th {
font-size: $font-size-sm;
@@ -234,6 +235,11 @@ table {
}
}
&.table > :not(caption) > * > * {
padding-right: $table-cell-padding-x-sm !important;
padding-left: $table-cell-padding-x-sm !important;
}
&.object-list {
th {
font-size: $font-size-xs;
@@ -808,7 +814,7 @@ table .table-badge-group {
}
&.badge:not(:last-of-type):not(:only-child) {
margin-bottom: map.get($spacers, 2);
margin-bottom: map.get($spacers, 1);
}
}
}

View File

@@ -70,6 +70,7 @@ $spacing-s: $input-padding-x;
span.arrow-down,
span.arrow-up {
border-color: currentColor;
color: $text-muted;
}
}
// Don't show the depth indicator outside of the menu.

View File

@@ -105,6 +105,11 @@
// Navbar brand
.sidenav-brand {
margin-right: 0;
transition: opacity 0.1s ease-in-out;
}
.sidenav-brand-icon {
transition: opacity 0.1s ease-in-out;
}
.sidenav-inner {
@@ -141,7 +146,17 @@
}
.sidenav-toggle {
display: none;
// The sidenav toggle's default state is "hidden". Because modifying the `display` property
// isn't ideal for smooth transitions, combine opacity 0 (transparent) and position absolute
// to yield a similar result.
position: absolute;
display: inline-block;
opacity: 0;
// The transition itself is largely irrelevant, but CSS needs *something* to transition in
// order to apply a delay.
transition: opacity 10ms ease-in-out;
// Offset the transition delay so the icon isn't visible during the logo transition.
transition-delay: 0.1s;
}
.sidenav-collapse {
@@ -350,13 +365,21 @@
.sidenav-brand {
position: absolute;
opacity: 0;
transform: translateX(-150%);
}
.sidenav-brand-icon {
opacity: 1;
}
.sidenav-toggle {
// Immediately hide the toggle when the sidenav is closed, so it doesn't linger and overlap
// with the logo elements.
opacity: 0;
position: absolute;
transition: unset;
transition-delay: 0ms;
}
.navbar-nav > .nav-item {
> .nav-link {
&:after {
@@ -402,7 +425,8 @@
@include media-breakpoint-up(lg) {
.sidenav-toggle {
display: inline-block;
position: relative;
opacity: 1;
}
}
}

View File

@@ -74,6 +74,7 @@ $btn-link-disabled-color: $gray-300;
// Forms
$component-active-bg: $primary;
$component-active-color: $black;
$form-text-color: $text-muted;
$input-bg: $gray-900;
$input-disabled-bg: $gray-700;

View File

@@ -7,6 +7,7 @@ $input-border-color: $gray-200;
$theme-colors: map-merge(
$theme-colors,
(
'primary': #337ab7,
'red': $red-500,
'yellow': $yellow-500,
'green': $green-500,

View File

@@ -23,7 +23,7 @@
--nbx-color-mode-toggle-color: #{$primary};
--nbx-sidenav-link-color: #{$gray-800};
--nbx-sidenav-pin-color: #{$orange};
--nbx-sidenav-parent-color: #{$gray-900};
--nbx-sidenav-parent-color: #{$gray-800};
--nbx-sidenav-group-color: #{$gray-800};
&[data-netbox-color-mode='dark'] {
@@ -49,7 +49,7 @@
--nbx-color-mode-toggle-color: #{$yellow-300};
--nbx-sidenav-link-color: #{$gray-200};
--nbx-sidenav-pin-color: #{$yellow};
--nbx-sidenav-parent-color: #{$gray-100};
--nbx-sidenav-parent-color: #{$gray-200};
--nbx-sidenav-group-color: #{$gray-600};
}
}

View File

@@ -6,11 +6,15 @@
lang="en"
data-netbox-url-name="{{ request.resolver_match.url_name }}"
data-netbox-base-path="{{ settings.BASE_PATH }}"
{% if preferences|get_key:'ui.colormode' == 'dark'%}
data-netbox-color-mode="dark"
{% else %}
data-netbox-color-mode="light"
{% endif %}
{% with preferences|get_key:'ui.colormode' as color_mode %}
{% if color_mode == 'dark'%}
data-netbox-color-mode="dark"
{% elif color_mode == 'light' %}
data-netbox-color-mode="light"
{% else %}
data-netbox-color-mode="unset"
{% endif %}
{% endwith %}
>
<head>
<meta charset="UTF-8" />
@@ -23,33 +27,77 @@
<title>{% block title %}Home{% endblock %} | NetBox</title>
<script type="text/javascript">
/**
* Set the color mode on the `<html/>` element and in local storage.
*
* @param mode {"dark" | "light"} NetBox Color Mode.
* @param inferred {boolean} Value is inferred from browser/system preference.
*/
function setMode(mode, inferred) {
document.documentElement.setAttribute("data-netbox-color-mode", mode);
localStorage.setItem("netbox-color-mode", mode);
localStorage.setItem("netbox-color-mode-inferred", inferred);
}
/**
* Determine the best initial color mode to use prior to rendering.
*/
(function() {
// Browser prefers dark color scheme.
var preferDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
// Browser prefers light color scheme.
var preferLight = window.matchMedia('(prefers-color-scheme: light)').matches;
// Client NetBox color-mode override.
var clientMode = localStorage.getItem('netbox-color-mode');
// NetBox server-rendered value.
var serverMode = document.documentElement.getAttribute('data-netbox-color-mode');
if ((clientMode !== null) && (clientMode !== serverMode)) {
// If the client mode is set, use its value over the server's value.
return document.documentElement.setAttribute('data-netbox-color-mode', clientMode);
}
if (preferDark && serverMode === 'light') {
// If the client value matches the server value, the browser preferrs dark-mode, but
// the server value doesn't match the browser preference, use dark mode.
return document.documentElement.setAttribute('data-netbox-color-mode', 'dark');
}
if (preferLight && serverMode === 'dark') {
// If the client value matches the server value, the browser preferrs dark-mode, but
// the server value doesn't match the browser preference, use light mode.
return document.documentElement.setAttribute('data-netbox-color-mode', 'light');
}
(function () {
try {
// Browser prefers dark color scheme.
var preferDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
// Browser prefers light color scheme.
var preferLight = window.matchMedia("(prefers-color-scheme: light)").matches;
// Client NetBox color-mode override.
var clientMode = localStorage.getItem("netbox-color-mode");
// NetBox server-rendered value.
var serverMode = document.documentElement.getAttribute("data-netbox-color-mode");
// Color mode is inferred from browser/system preference and not deterministically set by
// the client or server.
var inferred = JSON.parse(localStorage.getItem("netbox-color-mode-inferred"));
if (inferred === true && (serverMode === "light" || serverMode === "dark")) {
// The color mode was previously inferred from browser/system preference, but
// the server now has a value, so we should use the server's value.
return setMode(serverMode, false);
}
if (clientMode === null && (serverMode === "light" || serverMode === "dark")) {
// If the client mode is not set but the server mode is, use the server mode.
return setMode(serverMode, false);
}
if (clientMode !== null && serverMode === "unset") {
// The color mode has been set, deterministically or otherwise, and the server
// has no preference or has not been set. Use the client mode, but allow it to
/// be overridden by the server if/when a server value exists.
return setMode(clientMode, true);
}
if (
clientMode !== null &&
(serverMode === "light" || serverMode === "dark") &&
clientMode !== serverMode
) {
// If the client mode is set and is different than the server mode (which is also set),
// use the client mode over the server mode, as it should be more recent.
return setMode(clientMode, false);
}
if (clientMode === serverMode) {
// If the client and server modes match, use that value.
return setMode(clientMode, false);
}
if (preferDark && serverMode === "unset") {
// If the server mode is not set but the browser prefers dark mode, use dark mode, but
// allow it to be overridden by an explicit preference.
return setMode("dark", true);
}
if (preferLight && serverMode === "unset") {
// If the server mode is not set but the browser prefers light mode, use light mode,
// but allow it to be overridden by an explicit preference.
return setMode("light", true);
}
} catch (error) {
// In the event of an error, log it to the console and set the mode to light mode.
console.error(error);
}
return setMode("light", true);
})();
</script>

View File

@@ -72,6 +72,7 @@
<div class="col col-md-6">
{% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %}
{% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %}
{% include 'inc/image_attachments_panel.html' %}
{% plugin_right_page object %}
</div>
</div>

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