Compare commits

...

178 Commits

Author SHA1 Message Date
Jeremy Stretch
779249ff81 Merge pull request #8053 from netbox-community/develop
Release v3.1.1
2021-12-13 09:08:33 -05:00
jeremystretch
66d206a710 Release v3.1.1 2021-12-13 08:51:55 -05:00
jeremystretch
bfc1cab6df Fixes #8051: Contact group parent assignment should not be required under REST API 2021-12-13 08:22:48 -05:00
jeremystretch
5b0c79629e Closes #8047: Display sorting indicator in table column headers 2021-12-10 21:03:24 -05:00
jeremystretch
7922d3909a Fixes #8042: Fix filtering cables list by site slug or rack name 2021-12-10 16:41:03 -05:00
jeremystretch
ee6e2e0af1 Fixes #7690: Fix custom field integer support for MultiValueNumberFilter 2021-12-10 16:34:38 -05:00
jeremystretch
326a6be91c #7519: Update REST API tests 2021-12-10 15:45:22 -05:00
jeremystretch
58095e1916 Fixes #8038: Placeholder filter should display zero integer values 2021-12-10 15:38:51 -05:00
jeremystretch
3dae077b4d Fixes #8035: Redirect back to parent prefix after creating IP address(es) where applicable 2021-12-10 15:34:12 -05:00
jeremystretch
7c14c0812b Fixes #7519: Return a 409 status for unfulfillable available prefix/IP requests 2021-12-10 15:11:45 -05:00
Jeremy Stretch
3a05eda63a Merge pull request #8039 from netbox-community/5869-available-prefixes
Fixes #5869: Fix permissions evaluation under available prefix/IP REST API endpoints
2021-12-10 14:22:29 -05:00
jeremystretch
d850b3ac7e Fix available prefix creation test 2021-12-10 13:58:11 -05:00
jeremystretch
08de6c32c9 Changelog for #5869 2021-12-10 13:26:56 -05:00
jeremystretch
91fe158c26 Restore endpoint schema documentation 2021-12-10 13:23:49 -05:00
jeremystretch
661b3c4bfb Fix queryset restrictions 2021-12-10 12:52:48 -05:00
jeremystretch
35eabc0353 Move available IPs endpoints to separate views 2021-12-10 12:37:55 -05:00
jeremystretch
ef5bbdb1e2 Move available prefixes endpoint to its own view 2021-12-10 11:40:57 -05:00
jeremystretch
88fae2171d Closes #7691: Remove field_order from filterset forms 2021-12-10 08:57:19 -05:00
jeremystretch
de698154cd Fixes #8030: Validate custom field names 2021-12-09 15:19:19 -05:00
jeremystretch
1df05715c2 Fixes #8033: Fix display of zero values for custom integer fields in tables 2021-12-09 14:56:12 -05:00
jeremystretch
e5524da40e Fixes #8009: Validate IP addresses for uniqueness when creating an FHRP group 2021-12-09 13:46:19 -05:00
jeremystretch
50d393e0f9 Clean up user preferences view 2021-12-08 16:36:06 -05:00
jeremystretch
cd08836f3e Refresh user profile view; add recent activity 2021-12-08 16:32:31 -05:00
jeremystretch
45ac1cfd54 Fixes #8019: Exclude metrics endpoint when LOGIN_REQUIRED is true 2021-12-08 15:47:41 -05:00
jeremystretch
dda11ec69e Fixes #8003: Fix cable tracing across bridged interfaces with no cable 2021-12-08 11:35:50 -05:00
jeremystretch
7be6206d9d Fixes #8010: Allow filtering devices by multiple serial numbers 2021-12-08 11:08:19 -05:00
jeremystretch
4d896573b1 Fixes #8005: Fix contact email display 2021-12-08 11:04:03 -05:00
jeremystretch
988383648c Fixes #8001: Correct verbose name for wireless LAN group model 2021-12-08 10:45:27 -05:00
Daniel Sheppard
d59847537d Fix #7990 - Fix title display on contact view 2021-12-07 10:58:44 -06:00
jeremystretch
36859d89c8 Fixes #7996: Show WWN field in interface creation form 2021-12-07 10:59:08 -05:00
jeremystretch
ba8b593351 PRVB 2021-12-06 16:13:48 -05:00
Jeremy Stretch
5a59f2352c Merge pull request #7986 from netbox-community/develop
Release v3.1.0
2021-12-06 16:00:19 -05:00
jeremystretch
5164b78da1 Release v3.1.0 2021-12-06 15:01:36 -05:00
jeremystretch
5561b46a59 Finalize release notes 2021-12-06 14:58:49 -05:00
jeremystretch
26b2431cbf Bump django-taggit to 2.0.0 2021-12-06 14:38:13 -05:00
jeremystretch
029605f926 Clean up site view 2021-12-06 13:43:02 -05:00
jeremystretch
0cd173f9df Update django-taggit to 2.0 2021-12-06 13:25:09 -05:00
jeremystretch
414810bdf5 Update required dependencies 2021-12-06 13:15:17 -05:00
jeremystretch
f94c1e91ea Merge branch 'develop' into feature 2021-12-06 12:10:31 -05:00
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
b43980d660 Fixes #7960: Prevent creation of regions/site groups/locations with duplicate names (see #7354) 2021-12-03 15:09:56 -05:00
jeremystretch
09b612546b Omit actions column from non-paginated child object tables 2021-12-03 11:07:16 -05:00
jeremystretch
a99d14c13f Closes #7924: Include child groups on contact group view 2021-12-03 11:00:00 -05:00
jeremystretch
68f322a03b Closes #7925: Linkify contact phone and email attributes 2021-12-03 10:51:24 -05: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
3b25db919a Update changelog for #7940 2021-11-30 09:43:14 -06: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
870aa3a265 Merge branch 'develop' into feature 2021-11-24 14:00:37 -05: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
b7c9ca720a Closes #7886: Introduce a base FilterForm class 2021-11-19 15:12:45 -05:00
jeremystretch
7072f207c0 Call out all models with cable_peer name changes 2021-11-19 11:42:34 -05:00
jeremystretch
5f59f458f4 Fixes #7880: Include assigned IP addresses in FHRP group object representation 2021-11-19 11:34:59 -05:00
jeremystretch
b6fe613329 Fix redirection after creating FHRP group assignment 2021-11-19 10:42:13 -05:00
jeremystretch
cd128e557c Closes #7884: Add FHRP groups column to interface tables 2021-11-19 10:27:56 -05:00
Jeremy Stretch
30a5c70260 Merge pull request #7878 from netbox-community/7877-bootstrapmixin-cleanup
Closes #7877: BootstrapMixin cleanup
2021-11-18 17:05:29 -05:00
jeremystretch
beca978af5 Clean up imports 2021-11-18 16:48:29 -05:00
jeremystretch
98a830a6a0 Apply BootstrapMixin to ComponentForm 2021-11-18 16:32:22 -05:00
jeremystretch
ed2231e34b Apply BootstrapMixin to CustomFieldModelFilterForm 2021-11-18 16:27:06 -05:00
jeremystretch
55049bb303 Apply BootstrapMixin to BulkEditForm 2021-11-18 16:23:26 -05:00
jeremystretch
c210c6937b Apply BootstrapMixin to CustomFieldModelForm 2021-11-18 16:19:25 -05:00
jeremystretch
d2767f39f0 Closes #7850: Add note about where to assign FHRP groups 2021-11-18 15:12:12 -05:00
jeremystretch
1c9d39d3e6 Fix REST API version reporting for beta releases 2021-11-18 11:19:00 -05:00
jeremystretch
f16c6d81cf Merge branch 'develop' into feature 2021-11-18 11:06:54 -05: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
9a45547cda Closes #5143: Include a device's asset tag in its display value 2021-11-17 13:06:57 -05:00
jeremystretch
a000ded350 Remove "primary for" references to Device on IPAddress 2021-11-17 12:50:46 -05:00
jeremystretch
424ac29131 Closes #7812: Enable change logging for image attachments 2021-11-17 11:52:50 -05:00
jeremystretch
b7b5a5788f Fixes #7589: Correct 128GFC interface type identifier 2021-11-17 11:18:41 -05:00
jeremystretch
9de179cba8 Closes #7858: Standardize the representation of content types across import & export functions 2021-11-17 11:02:22 -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
6f7fbf7686 Fixes #7849: Fix exception when creating an FHRPGroup with an invalid IP address 2021-11-16 16:50:20 -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
500f213c6b Fix erroneous merge resolution 2021-11-12 09:29:55 -05:00
jeremystretch
cede27b5fe Merge branch 'develop' into feature 2021-11-12 09:09:15 -05: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
d529c1b5b3 Housekeeping: Use RestrictedQuerySet for default manager on base models 2021-11-11 15:04:22 -05:00
jeremystretch
834f68e6e4 Fixes #7761: Extend cable tracing across bridged interfaces 2021-11-11 14:45:10 -05:00
jeremystretch
83b2102705 Closes #7769: Enable assignment of IP addresses to an existing FHRP group 2021-11-11 14:05:35 -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
34f24de3e4 Fixes #7757: Fix 404 when assigning multiple contacts/FHRP groups in succession 2021-11-09 17:08:28 -05:00
jeremystretch
f93d6813a9 Merge branch 'develop' into feature 2021-11-09 16:52:36 -05: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
Jeremy Stretch
c0653da736 Merge pull request #7778 from netbox-community/7775-dynamic-config
7775 dynamic config
2021-11-08 15:54:25 -05:00
jeremystretch
f3d8f1b1fb Changelog for #7775 2021-11-08 15:38:55 -05:00
jeremystretch
d2391b9c63 Move GRAPHQL_ENABLED to dynamic configuration 2021-11-08 15:31:09 -05:00
jeremystretch
f8e44c09eb Move CUSTOM_VALIDATORS to dynamic configuration 2021-11-08 15:22:29 -05:00
jeremystretch
2a00519b93 Move CHANGELOG_RETENTION to dyanmic configuration 2021-11-08 15:07:58 -05:00
jeremystretch
3292a2aecc Closes #7619: Permit custom validation rules to be defined as plain data or dotted path to class 2021-11-08 14:52:56 -05:00
Flo
b7aa44837f Add Mini-DIN 8 Console-Port-Type 2021-11-08 17:50:13 +01:00
jeremystretch
17fd6e692e Fixes #7768: Validate IP address status when creating a new FHRP group 2021-11-08 08:40:24 -05:00
jeremystretch
2ce8ef5704 Fixes #7771: Group assignment should be optional when creating contacts via REST API 2021-11-08 08:34:10 -05: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
f7d0db9cd2 Fixes #7756: Fix AttributeError exception when editing an IP address assigned to a FHRPGroup 2021-11-05 13:16:43 -04:00
jeremystretch
fab1d3651b Add new models to developer docs 2021-11-05 13:10:27 -04:00
jeremystretch
e5d7578663 Fixes #7750: Fix cable trace image link 2021-11-05 11:10:17 -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
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
160 changed files with 2446 additions and 1921 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.9
description: What version of NetBox are you currently running?
placeholder: v3.1.1
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.9
placeholder: v3.1.1
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

@@ -1,6 +1,6 @@
# The Python web framework on which NetBox is built
# https://github.com/django/django
Django
Django<4.0
# Django middleware which permits cross-domain API requests
# https://github.com/OttoYiu/django-cors-headers

View File

@@ -8,7 +8,7 @@ NetBox includes a `housekeeping` management command that should be run nightly.
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.
```shell
ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
```
!!! note

View File

@@ -31,6 +31,41 @@ This defines custom content to be displayed on the login page above the login fo
---
## CHANGELOG_RETENTION
Default: 90
The number of days to retain logged changes (object creations, updates, and deletions). Set this to `0` to retain
changes in the database indefinitely.
!!! warning
If enabling indefinite changelog retention, it is recommended to periodically delete old entries. Otherwise, the database may eventually exceed capacity.
---
## CUSTOM_VALIDATORS
This is a mapping of models to [custom validators](../customization/custom-validation.md) that have been defined locally to enforce custom validation logic. An example is provided below:
```python
CUSTOM_VALIDATORS = {
"dcim.site": [
{
"name": {
"min_length": 5,
"max_length": 30
}
},
"my_plugin.validators.Validator1"
],
"dim.device": [
"my_plugin.validators.Validator1"
]
}
```
---
## ENFORCE_GLOBAL_UNIQUE
Default: False
@@ -39,6 +74,14 @@ By default, NetBox will permit users to create duplicate prefixes and IP address
---
## GRAPHQL_ENABLED
Default: True
Setting this to False will disable the GraphQL API.
---
## MAINTENANCE_MODE
Default: False

View File

@@ -25,18 +25,6 @@ BASE_PATH = 'netbox/'
---
## CHANGELOG_RETENTION
Default: 90
The number of days to retain logged changes (object creations, updates, and deletions). Set this to `0` to retain
changes in the database indefinitely.
!!! warning
If enabling indefinite changelog retention, it is recommended to periodically delete old entries. Otherwise, the database may eventually exceed capacity.
---
## CORS_ORIGIN_ALLOW_ALL
Default: False
@@ -61,22 +49,6 @@ CORS_ORIGIN_WHITELIST = [
---
## CUSTOM_VALIDATORS
This is a mapping of models to [custom validators](../customization/custom-validation.md) that have been defined locally to enforce custom validation logic. An example is provided below:
```python
CUSTOM_VALIDATORS = {
'dcim.site': (
Validator1,
Validator2,
Validator3
)
}
```
---
## DEBUG
Default: False
@@ -168,14 +140,6 @@ EXEMPT_VIEW_PERMISSIONS = ['*']
---
## GRAPHQL_ENABLED
Default: True
Setting this to False will disable the GraphQL API.
---
## HTTP_PROXIES
Default: None

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

@@ -1,22 +1,18 @@
# Custom Validation
NetBox validates every object prior to it being written to the database to ensure data integrity. This validation includes things like checking for proper formatting and that references to related objects are valid. However, you may wish to supplement this validation with some rules of your own. For example, perhaps you require that every site's name conforms to a specific pattern. This can be done using NetBox's `CustomValidator` class.
NetBox validates every object prior to it being written to the database to ensure data integrity. This validation includes things like checking for proper formatting and that references to related objects are valid. However, you may wish to supplement this validation with some rules of your own. For example, perhaps you require that every site's name conforms to a specific pattern. This can be done using custom validation rules.
## CustomValidator
## Custom Validation Rules
### Validation Rules
Custom validation rules are expressed as a mapping of model attributes to a set of rules to which that attribute must conform. For example:
A custom validator can be instantiated by passing a mapping of attributes to a set of rules to which that attribute must conform. For example:
```python
from extras.validators import CustomValidator
CustomValidator({
'name': {
'min_length': 5,
'max_length': 30,
}
})
```json
{
"name": {
"min_length": 5,
"max_length": 30
}
}
```
This defines a custom validator which checks that the length of the `name` attribute for an object is at least five characters long, and no longer than 30 characters. This validation is executed _after_ NetBox has performed its own internal validation.
@@ -38,12 +34,13 @@ The `min` and `max` types should be defined for numeric values, whereas `min_len
### Custom Validation Logic
There may be instances where the provided validation types are insufficient. The `CustomValidator` class can be extended to enforce arbitrary validation logic by overriding its `validate()` method, and calling `fail()` when an unsatisfactory condition is detected.
There may be instances where the provided validation types are insufficient. NetBox provides a `CustomValidator` class which can be extended to enforce arbitrary validation logic by overriding its `validate()` method, and calling `fail()` when an unsatisfactory condition is detected.
```python
from extras.validators import CustomValidator
class MyValidator(CustomValidator):
def validate(self, instance):
if instance.status == 'active' and not instance.description:
self.fail("Active sites must have a description set!", field='status')
@@ -53,34 +50,69 @@ The `fail()` method may optionally specify a field with which to associate the s
## Assigning Custom Validators
Custom validators are associated with specific NetBox models under the [CUSTOM_VALIDATORS](../configuration/optional-settings.md#custom_validators) configuration parameter, as such:
Custom validators are associated with specific NetBox models under the [CUSTOM_VALIDATORS](../configuration/optional-settings.md#custom_validators) configuration parameter. There are three manners by which custom validation rules can be defined:
1. Plain JSON mapping (no custom logic)
2. Dotted path to a custom validator class
3. Direct reference to a custom validator class
### Plain Data
For cases where custom logic is not needed, it is sufficient to pass validation rules as plain JSON-compatible objects. This approach typically affords the most portability for your configuration. For instance:
```python
CUSTOM_VALIDATORS = {
"dcim.site": [
{
"name": {
"min_length": 5,
"max_length": 30,
}
}
],
"dcim.device": [
{
"platform": {
"required": True,
}
}
]
}
```
### Dotted Path
In instances where a custom validator class is needed, it can be referenced by its Python path (relative to NetBox's working directory):
```python
CUSTOM_VALIDATORS = {
'dcim.site': (
'my_validators.Validator1',
'my_validators.Validator2',
),
'dcim.device': (
'my_validators.Validator3',
)
}
```
### Direct Class Reference
This approach requires each class being instantiated to be imported directly within the Python configuration file.
```python
from my_validators import Validator1, Validator2, Validator3
CUSTOM_VALIDATORS = {
'dcim.site': (
Validator1,
Validator2,
Validator3
),
'dcim.device': (
Validator3,
)
}
```
!!! note
Even if defining only a single validator, it must be passed as an iterable.
When it is not necessary to define a custom `validate()` method, you may opt to pass a `CustomValidator` instance directly:
```python
from extras.validators import CustomValidator
CUSTOM_VALIDATORS = {
'dcim.site': (
CustomValidator({
'name': {
'min_length': 5,
'max_length': 30,
}
}),
)
}
```

View File

@@ -41,15 +41,20 @@ The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/
* [dcim.Site](../models/dcim/site.md)
* [dcim.VirtualChassis](../models/dcim/virtualchassis.md)
* [ipam.Aggregate](../models/ipam/aggregate.md)
* [ipam.ASN](../models/ipam/asn.md)
* [ipam.FHRPGroup](../models/ipam/fhrpgroup.md)
* [ipam.IPAddress](../models/ipam/ipaddress.md)
* [ipam.Prefix](../models/ipam/prefix.md)
* [ipam.RouteTarget](../models/ipam/routetarget.md)
* [ipam.Service](../models/ipam/service.md)
* [ipam.VLAN](../models/ipam/vlan.md)
* [ipam.VRF](../models/ipam/vrf.md)
* [tenancy.Contact](../models/tenancy/contact.md)
* [tenancy.Tenant](../models/tenancy/tenant.md)
* [virtualization.Cluster](../models/virtualization/cluster.md)
* [virtualization.VirtualMachine](../models/virtualization/virtualmachine.md)
* [wireless.WirelessLAN](../models/wireless/wirelesslan.md)
* [wireless.WirelessLink](../models/wireless/wirelesslink.md)
### Organizational Models
@@ -61,6 +66,7 @@ The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/
* [ipam.RIR](../models/ipam/rir.md)
* [ipam.Role](../models/ipam/role.md)
* [ipam.VLANGroup](../models/ipam/vlangroup.md)
* [tenancy.ContactRole](../models/tenancy/contactrole.md)
* [virtualization.ClusterGroup](../models/virtualization/clustergroup.md)
* [virtualization.ClusterType](../models/virtualization/clustertype.md)
@@ -69,7 +75,9 @@ The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/
* [dcim.Location](../models/dcim/location.md) (formerly RackGroup)
* [dcim.Region](../models/dcim/region.md)
* [dcim.SiteGroup](../models/dcim/sitegroup.md)
* [tenancy.ContactGroup](../models/tenancy/contactgroup.md)
* [tenancy.TenantGroup](../models/tenancy/tenantgroup.md)
* [wireless.WirelessLANGroup](../models/wireless/wirelesslangroup.md)
### Component Models

View File

@@ -267,7 +267,7 @@ NetBox includes a `housekeeping` management command that handles some recurring
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
ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
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

@@ -114,7 +114,7 @@ sudo systemctl restart netbox netbox-rq
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
ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
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

@@ -20,7 +20,7 @@ Custom fields may be created by navigating to Customization > Custom Fields. Net
* Selection: A selection of one of several pre-defined custom choices
* Multiple selection: A selection field which supports the assignment of multiple values
Each custom field must have a name; this should be a simple database-friendly string, e.g. `tps_report`. You may also assign a corresponding human-friendly label (e.g. "TPS report"); the label will be displayed on web forms. A weight is also required: Higher-weight fields will be ordered lower within a form. (The default weight is 100.) If a description is provided, it will appear beneath the field in a form.
Each custom field must have a name. This should be a simple database-friendly string (e.g. `tps_report`) and may contain only alphanumeric characters and underscores. You may also assign a corresponding human-friendly label (e.g. "TPS report"); the label will be displayed on web forms. A weight is also required: Higher-weight fields will be ordered lower within a form. (The default weight is 100.) If a description is provided, it will appear beneath the field in a form.
Marking a field as required will force the user to provide a value for the field when creating a new object or when saving an existing object. A default value for the field may also be provided. Use "true" or "false" for boolean fields, or the exact value of a choice for selection fields.

View File

@@ -12,3 +12,5 @@ NetBox models these redundancy groups by protocol and group ID. Each group may o
## FHRP Group Assignments
Member device and VM interfaces can be assigned to FHRP groups, along with a numeric priority value. For instance, three interfaces, each belonging to a different router, may each be assigned to the same FHRP group to serve a common virtual IP address. Each of these assignments would typically receive a different priority.
Interfaces are assigned to FHRP groups under the interface detail view.

View File

@@ -1,10 +1,72 @@
# NetBox v3.0
## v3.0.10 (FUTURE)
## 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
---
@@ -404,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

@@ -1,4 +1,34 @@
## v3.1-beta1 (2021-11-05)
# NetBox v3.1
## v3.1.1 (2021-12-13)
### Enhancements
* [#8047](https://github.com/netbox-community/netbox/issues/8047) - Display sorting indicator in table column headers
### Bug Fixes
* [#5869](https://github.com/netbox-community/netbox/issues/5869) - Fix permissions evaluation under available prefix/IP REST API endpoints
* [#7519](https://github.com/netbox-community/netbox/issues/7519) - Return a 409 status for unfulfillable available prefix/IP requests
* [#7690](https://github.com/netbox-community/netbox/issues/7690) - Fix custom field integer support for MultiValueNumberFilter
* [#7990](https://github.com/netbox-community/netbox/issues/7990) - Fix `title` display on contact detail view
* [#7996](https://github.com/netbox-community/netbox/issues/7996) - Show WWN field in interface creation form
* [#8001](https://github.com/netbox-community/netbox/issues/8001) - Correct verbose name for wireless LAN group model
* [#8003](https://github.com/netbox-community/netbox/issues/8003) - Fix cable tracing across bridged interfaces with no cable
* [#8005](https://github.com/netbox-community/netbox/issues/8005) - Fix contact email display
* [#8009](https://github.com/netbox-community/netbox/issues/8009) - Validate IP addresses for uniqueness when creating an FHRP group
* [#8010](https://github.com/netbox-community/netbox/issues/8010) - Allow filtering devices by multiple serial numbers
* [#8019](https://github.com/netbox-community/netbox/issues/8019) - Exclude metrics endpoint when `LOGIN_REQUIRED` is true
* [#8030](https://github.com/netbox-community/netbox/issues/8030) - Validate custom field names
* [#8033](https://github.com/netbox-community/netbox/issues/8033) - Fix display of zero values for custom integer fields in tables
* [#8035](https://github.com/netbox-community/netbox/issues/8035) - Redirect back to parent prefix after creating IP address(es) where applicable
* [#8038](https://github.com/netbox-community/netbox/issues/8038) - Placeholder filter should display zero integer values
* [#8042](https://github.com/netbox-community/netbox/issues/8042) - Fix filtering cables list by site slug or rack name
* [#8051](https://github.com/netbox-community/netbox/issues/8051) - Contact group parent assignment should not be required under REST API
---
## v3.1.0 (2021-12-06)
!!! warning "PostgreSQL 10 Required"
NetBox v3.1 requires PostgreSQL 10 or later.
@@ -7,6 +37,8 @@
* The `tenant` and `tenant_id` filters for the Cable model now filter on the tenant assigned directly to each cable, rather than on the parent object of either termination.
* The `cable_peer` and `cable_peer_type` attributes of cable termination models have been renamed to `link_peer` and `link_peer_type`, respectively, to accommodate wireless links between interfaces.
* Exported webhooks and custom fields now reference associated content types by raw string value (e.g. "dcim.site") rather than by human-friendly name.
* The 128GFC interface type has been corrected from `128gfc-sfp28` to `128gfc-qsfp28`.
### New Features
@@ -76,6 +108,7 @@ Support for single sign-on (SSO) authentication has been added via the [python-s
* [#1337](https://github.com/netbox-community/netbox/issues/1337) - Add WWN field to interfaces
* [#1943](https://github.com/netbox-community/netbox/issues/1943) - Relax uniqueness constraint on cluster names
* [#3839](https://github.com/netbox-community/netbox/issues/3839) - Add `airflow` field for devices types and devices
* [#5143](https://github.com/netbox-community/netbox/issues/5143) - Include a device's asset tag in its display value
* [#6497](https://github.com/netbox-community/netbox/issues/6497) - Extend tag support to organizational models
* [#6615](https://github.com/netbox-community/netbox/issues/6615) - Add filter lookups for custom fields
* [#6711](https://github.com/netbox-community/netbox/issues/6711) - Add `longtext` custom field type with Markdown support
@@ -85,6 +118,14 @@ Support for single sign-on (SSO) authentication has been added via the [python-s
* [#7452](https://github.com/netbox-community/netbox/issues/7452) - Add `json` custom field type
* [#7530](https://github.com/netbox-community/netbox/issues/7530) - Move device type component lists to separate views
* [#7606](https://github.com/netbox-community/netbox/issues/7606) - Model transmit power for interfaces
* [#7619](https://github.com/netbox-community/netbox/issues/7619) - Permit custom validation rules to be defined as plain data or dotted path to class
* [#7761](https://github.com/netbox-community/netbox/issues/7761) - Extend cable tracing across bridged interfaces
* [#7812](https://github.com/netbox-community/netbox/issues/7812) - Enable change logging for image attachments
* [#7858](https://github.com/netbox-community/netbox/issues/7858) - Standardize the representation of content types across import & export functions
### Bug Fixes
* [#7589](https://github.com/netbox-community/netbox/issues/7589) - Correct 128GFC interface type identifier
### Other Changes
@@ -123,12 +164,25 @@ Support for single sign-on (SSO) authentication has been added via the [python-s
* tenancy.TenantGroup
* virtualization.ClusterGroup
* virtualization.ClusterType
* circuits.CircuitTermination
* `cable_peer` has been renamed to `link_peer`
* `cable_peer_type` has been renamed to `link_peer_type`
* dcim.Cable
* Added `tenant` field
* dcim.ConsolePort
* `cable_peer` has been renamed to `link_peer`
* `cable_peer_type` has been renamed to `link_peer_type`
* dcim.ConsoleServerPort
* `cable_peer` has been renamed to `link_peer`
* `cable_peer_type` has been renamed to `link_peer_type`
* dcim.Device
* The `display` field now includes the device's asset tag, if set
* Added `airflow` field
* dcim.DeviceType
* Added `airflow` field
* dcim.FrontPort
* `cable_peer` has been renamed to `link_peer`
* `cable_peer_type` has been renamed to `link_peer_type`
* dcim.Interface
* `cable_peer` has been renamed to `link_peer`
* `cable_peer_type` has been renamed to `link_peer_type`
@@ -143,8 +197,22 @@ Support for single sign-on (SSO) authentication has been added via the [python-s
* Added `count_fhrp_groups` read-only field
* dcim.Location
* Added `tenant` field
* dcim.PowerFeed
* `cable_peer` has been renamed to `link_peer`
* `cable_peer_type` has been renamed to `link_peer_type`
* dcim.PowerOutlet
* `cable_peer` has been renamed to `link_peer`
* `cable_peer_type` has been renamed to `link_peer_type`
* dcim.PowerPort
* `cable_peer` has been renamed to `link_peer`
* `cable_peer_type` has been renamed to `link_peer_type`
* dcim.RearPort
* `cable_peer` has been renamed to `link_peer`
* `cable_peer_type` has been renamed to `link_peer_type`
* dcim.Site
* Added `asns` relationship to ipam.ASN
* extras.ImageAttachment
* Added the `last_updated` field
* extras.Webhook
* Added the `conditions` field
* virtualization.VMInterface

View File

@@ -4,9 +4,7 @@ from circuits.choices import CircuitStatusChoices
from circuits.models import *
from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm
from tenancy.models import Tenant
from utilities.forms import (
add_blank_choice, BootstrapMixin, CommentField, DynamicModelChoiceField, SmallTextarea, StaticSelect,
)
from utilities.forms import add_blank_choice, CommentField, DynamicModelChoiceField, SmallTextarea, StaticSelect
__all__ = (
'CircuitBulkEditForm',
@@ -16,7 +14,7 @@ __all__ = (
)
class ProviderBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
class ProviderBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Provider.objects.all(),
widget=forms.MultipleHiddenInput
@@ -55,7 +53,7 @@ class ProviderBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBu
]
class ProviderNetworkBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
class ProviderNetworkBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=ProviderNetwork.objects.all(),
widget=forms.MultipleHiddenInput
@@ -79,7 +77,7 @@ class ProviderNetworkBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomField
]
class CircuitTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
class CircuitTypeBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=CircuitType.objects.all(),
widget=forms.MultipleHiddenInput
@@ -93,7 +91,7 @@ class CircuitTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldMode
nullable_fields = ['description']
class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
class CircuitBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Circuit.objects.all(),
widget=forms.MultipleHiddenInput

View File

@@ -6,7 +6,7 @@ from circuits.models import *
from dcim.models import Region, Site, SiteGroup
from extras.forms import CustomFieldModelFilterForm
from tenancy.forms import TenancyFilterForm
from utilities.forms import BootstrapMixin, DynamicModelMultipleChoiceField, StaticSelectMultiple, TagFilterField
from utilities.forms import DynamicModelMultipleChoiceField, StaticSelectMultiple, TagFilterField
__all__ = (
'CircuitFilterForm',
@@ -16,18 +16,13 @@ __all__ = (
)
class ProviderFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
class ProviderFilterForm(CustomFieldModelFilterForm):
model = Provider
field_groups = [
['q', 'tag'],
['region_id', 'site_group_id', 'site_id'],
['asn'],
]
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -57,17 +52,12 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
tag = TagFilterField(model)
class ProviderNetworkFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
class ProviderNetworkFilterForm(CustomFieldModelFilterForm):
model = ProviderNetwork
field_groups = (
('q', 'tag'),
('provider_id',),
)
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
provider_id = DynamicModelMultipleChoiceField(
queryset=Provider.objects.all(),
required=False,
@@ -77,17 +67,12 @@ class ProviderNetworkFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
tag = TagFilterField(model)
class CircuitTypeFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
class CircuitTypeFilterForm(CustomFieldModelFilterForm):
model = CircuitType
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
tag = TagFilterField(model)
class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
class CircuitFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
model = Circuit
field_groups = [
['q', 'tag'],
@@ -96,11 +81,6 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilte
['region_id', 'site_group_id', 'site_id'],
['tenant_group_id', 'tenant_id'],
]
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
type_id = DynamicModelMultipleChoiceField(
queryset=CircuitType.objects.all(),
required=False,

View File

@@ -19,7 +19,7 @@ __all__ = (
)
class ProviderForm(BootstrapMixin, CustomFieldModelForm):
class ProviderForm(CustomFieldModelForm):
slug = SlugField()
comments = CommentField()
tags = DynamicModelMultipleChoiceField(
@@ -53,7 +53,7 @@ class ProviderForm(BootstrapMixin, CustomFieldModelForm):
}
class ProviderNetworkForm(BootstrapMixin, CustomFieldModelForm):
class ProviderNetworkForm(CustomFieldModelForm):
provider = DynamicModelChoiceField(
queryset=Provider.objects.all()
)
@@ -73,7 +73,7 @@ class ProviderNetworkForm(BootstrapMixin, CustomFieldModelForm):
)
class CircuitTypeForm(BootstrapMixin, CustomFieldModelForm):
class CircuitTypeForm(CustomFieldModelForm):
slug = SlugField()
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
@@ -87,7 +87,7 @@ class CircuitTypeForm(BootstrapMixin, CustomFieldModelForm):
]
class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
class CircuitForm(TenancyForm, CustomFieldModelForm):
provider = DynamicModelChoiceField(
queryset=Provider.objects.all()
)

View File

@@ -7,7 +7,6 @@ from circuits.choices import *
from dcim.models import LinkTermination
from extras.utils import extras_features
from netbox.models import ChangeLoggedModel, OrganizationalModel, PrimaryModel
from utilities.querysets import RestrictedQuerySet
__all__ = (
'Circuit',
@@ -35,8 +34,6 @@ class CircuitType(OrganizationalModel):
blank=True,
)
objects = RestrictedQuerySet.as_manager()
class Meta:
ordering = ['name']
@@ -123,8 +120,6 @@ class Circuit(PrimaryModel):
null=True
)
objects = RestrictedQuerySet.as_manager()
clone_fields = [
'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description',
]
@@ -195,8 +190,6 @@ class CircuitTermination(ChangeLoggedModel, LinkTermination):
blank=True
)
objects = RestrictedQuerySet.as_manager()
class Meta:
ordering = ['circuit', 'term_side']
unique_together = ['circuit', 'term_side']

View File

@@ -59,8 +59,6 @@ class Provider(PrimaryModel):
to='tenancy.ContactAssignment'
)
objects = RestrictedQuerySet.as_manager()
clone_fields = [
'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact',
]
@@ -97,8 +95,6 @@ class ProviderNetwork(PrimaryModel):
blank=True
)
objects = RestrictedQuerySet.as_manager()
class Meta:
ordering = ('provider', 'name')
constraints = (

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

@@ -352,7 +352,8 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
required=False
)
power_port = NestedPowerPortTemplateSerializer(
required=False
required=False,
allow_null=True
)
feed_leg = ChoiceField(
choices=PowerOutletFeedLegChoices,
@@ -524,7 +525,7 @@ class ConsoleServerPortSerializer(PrimaryModelSerializer, LinkTerminationSeriali
)
speed = ChoiceField(
choices=ConsolePortSpeedChoices,
allow_blank=True,
allow_null=True,
required=False
)
cable = NestedCableSerializer(read_only=True)
@@ -548,7 +549,7 @@ class ConsolePortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, C
)
speed = ChoiceField(
choices=ConsolePortSpeedChoices,
allow_blank=True,
allow_null=True,
required=False
)
cable = NestedCableSerializer(read_only=True)
@@ -571,7 +572,8 @@ class PowerOutletSerializer(PrimaryModelSerializer, LinkTerminationSerializer, C
required=False
)
power_port = NestedPowerPortSerializer(
required=False
required=False,
allow_null=True
)
feed_leg = ChoiceField(
choices=PowerOutletFeedLegChoices,

View File

@@ -204,6 +204,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'
@@ -221,6 +222,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'),
@@ -329,6 +331,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'
@@ -434,6 +437,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'),
@@ -445,7 +449,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)'),
@@ -550,6 +554,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'
@@ -569,6 +574,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'
@@ -647,6 +653,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'),
@@ -657,8 +664,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'),
@@ -668,6 +675,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'),
@@ -757,6 +765,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'
@@ -780,7 +789,7 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_16GFC_SFP_PLUS = '16gfc-sfpp'
TYPE_32GFC_SFP28 = '32gfc-sfp28'
TYPE_64GFC_QSFP_PLUS = '64gfc-qsfpp'
TYPE_128GFC_QSFP28 = '128gfc-sfp28'
TYPE_128GFC_QSFP28 = '128gfc-qsfp28'
# InfiniBand
TYPE_INFINIBAND_SDR = 'infiniband-sdr'
@@ -869,6 +878,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

@@ -718,7 +718,7 @@ class DeviceFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConfigContex
field_name='interfaces__mac_address',
label='MAC address',
)
serial = django_filters.CharFilter(
serial = MultiValueCharFilter(
lookup_expr='iexact'
)
has_primary_ip = django_filters.BooleanFilter(
@@ -876,6 +876,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):
@@ -1247,7 +1258,7 @@ class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet):
method='filter_device',
field_name='device__rack_id'
)
rack = MultiValueNumberFilter(
rack = MultiValueCharFilter(
method='filter_device',
field_name='device__rack__name'
)
@@ -1255,7 +1266,7 @@ class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet):
method='filter_device',
field_name='device__site_id'
)
site = MultiValueNumberFilter(
site = MultiValueCharFilter(
method='filter_device',
field_name='device__site__slug'
)
@@ -1416,6 +1427,10 @@ class PowerFeedFilterSet(PrimaryModelFilterSet, CableTerminationFilterSet, PathE
#
class ConnectionFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
site_id = MultiValueNumberFilter(
method='filter_connections',
field_name='device__site_id'
@@ -1438,6 +1453,15 @@ class ConnectionFilterSet(BaseFilterSet):
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):

View File

@@ -3,7 +3,7 @@ from django import forms
from dcim.models import *
from extras.forms import CustomFieldsMixin
from extras.models import Tag
from utilities.forms import BootstrapMixin, DynamicModelMultipleChoiceField, form_from_model
from utilities.forms import DynamicModelMultipleChoiceField, form_from_model
from .object_create import ComponentForm
__all__ = (
@@ -23,7 +23,7 @@ __all__ = (
# Device components
#
class DeviceBulkAddComponentForm(BootstrapMixin, CustomFieldsMixin, ComponentForm):
class DeviceBulkAddComponentForm(CustomFieldsMixin, ComponentForm):
pk = forms.ModelMultipleChoiceField(
queryset=Device.objects.all(),
widget=forms.MultipleHiddenInput()

View File

@@ -11,8 +11,8 @@ from ipam.constants import BGP_ASN_MIN, BGP_ASN_MAX
from ipam.models import VLAN, ASN
from tenancy.models import Tenant
from utilities.forms import (
add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorField, CommentField,
DynamicModelChoiceField, DynamicModelMultipleChoiceField, form_from_model, SmallTextarea, StaticSelect,
add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, CommentField, DynamicModelChoiceField,
DynamicModelMultipleChoiceField, form_from_model, SmallTextarea, StaticSelect,
)
__all__ = (
@@ -52,7 +52,7 @@ __all__ = (
)
class RegionBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
class RegionBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Region.objects.all(),
widget=forms.MultipleHiddenInput
@@ -70,7 +70,7 @@ class RegionBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulk
nullable_fields = ['parent', 'description']
class SiteGroupBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
class SiteGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
widget=forms.MultipleHiddenInput
@@ -88,7 +88,7 @@ class SiteGroupBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelB
nullable_fields = ['parent', 'description']
class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
class SiteBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Site.objects.all(),
widget=forms.MultipleHiddenInput
@@ -138,7 +138,7 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEd
]
class LocationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
class LocationBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Location.objects.all(),
widget=forms.MultipleHiddenInput
@@ -167,7 +167,7 @@ class LocationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBu
nullable_fields = ['parent', 'tenant', 'description']
class RackRoleBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
class RackRoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=RackRole.objects.all(),
widget=forms.MultipleHiddenInput
@@ -184,7 +184,7 @@ class RackRoleBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBu
nullable_fields = ['color', 'description']
class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
class RackBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Rack.objects.all(),
widget=forms.MultipleHiddenInput
@@ -284,7 +284,7 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEd
]
class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
class RackReservationBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=RackReservation.objects.all(),
widget=forms.MultipleHiddenInput()
@@ -309,7 +309,7 @@ class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomField
nullable_fields = []
class ManufacturerBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
class ManufacturerBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
widget=forms.MultipleHiddenInput
@@ -323,7 +323,7 @@ class ManufacturerBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldMod
nullable_fields = ['description']
class DeviceTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
class DeviceTypeBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=DeviceType.objects.all(),
widget=forms.MultipleHiddenInput()
@@ -351,7 +351,7 @@ class DeviceTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModel
nullable_fields = ['airflow']
class DeviceRoleBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
class DeviceRoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=DeviceRole.objects.all(),
widget=forms.MultipleHiddenInput
@@ -373,7 +373,7 @@ class DeviceRoleBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModel
nullable_fields = ['color', 'description']
class PlatformBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
class PlatformBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Platform.objects.all(),
widget=forms.MultipleHiddenInput
@@ -396,7 +396,7 @@ class PlatformBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBu
nullable_fields = ['manufacturer', 'napalm_driver', 'description']
class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
class DeviceBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Device.objects.all(),
widget=forms.MultipleHiddenInput()
@@ -457,7 +457,7 @@ class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulk
]
class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
class CableBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Cable.objects.all(),
widget=forms.MultipleHiddenInput
@@ -513,7 +513,7 @@ class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkE
})
class VirtualChassisBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
class VirtualChassisBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=VirtualChassis.objects.all(),
widget=forms.MultipleHiddenInput()
@@ -527,7 +527,7 @@ class VirtualChassisBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldM
nullable_fields = ['domain']
class PowerPanelBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
class PowerPanelBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=PowerPanel.objects.all(),
widget=forms.MultipleHiddenInput
@@ -566,7 +566,7 @@ class PowerPanelBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModel
nullable_fields = ['location']
class PowerFeedBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
class PowerFeedBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=PowerFeed.objects.all(),
widget=forms.MultipleHiddenInput
@@ -631,7 +631,7 @@ class PowerFeedBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelB
# Device component templates
#
class ConsolePortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
class ConsolePortTemplateBulkEditForm(BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=ConsolePortTemplate.objects.all(),
widget=forms.MultipleHiddenInput()
@@ -650,7 +650,7 @@ class ConsolePortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
nullable_fields = ('label', 'type', 'description')
class ConsoleServerPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
class ConsoleServerPortTemplateBulkEditForm(BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=ConsoleServerPortTemplate.objects.all(),
widget=forms.MultipleHiddenInput()
@@ -672,7 +672,7 @@ class ConsoleServerPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
nullable_fields = ('label', 'type', 'description')
class PowerPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
class PowerPortTemplateBulkEditForm(BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=PowerPortTemplate.objects.all(),
widget=forms.MultipleHiddenInput()
@@ -704,7 +704,7 @@ class PowerPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
nullable_fields = ('label', 'type', 'maximum_draw', 'allocated_draw', 'description')
class PowerOutletTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
class PowerOutletTemplateBulkEditForm(BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=PowerOutletTemplate.objects.all(),
widget=forms.MultipleHiddenInput()
@@ -752,7 +752,7 @@ class PowerOutletTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
self.fields['power_port'].widget.attrs['disabled'] = True
class InterfaceTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
class InterfaceTemplateBulkEditForm(BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=InterfaceTemplate.objects.all(),
widget=forms.MultipleHiddenInput()
@@ -779,7 +779,7 @@ class InterfaceTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
nullable_fields = ('label', 'description')
class FrontPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
class FrontPortTemplateBulkEditForm(BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=FrontPortTemplate.objects.all(),
widget=forms.MultipleHiddenInput()
@@ -804,7 +804,7 @@ class FrontPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
nullable_fields = ('description',)
class RearPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
class RearPortTemplateBulkEditForm(BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=RearPortTemplate.objects.all(),
widget=forms.MultipleHiddenInput()
@@ -829,7 +829,7 @@ class RearPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
nullable_fields = ('description',)
class DeviceBayTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
class DeviceBayTemplateBulkEditForm(BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=DeviceBayTemplate.objects.all(),
widget=forms.MultipleHiddenInput()
@@ -852,7 +852,6 @@ class DeviceBayTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
class ConsolePortBulkEditForm(
form_from_model(ConsolePort, ['label', 'type', 'speed', 'mark_connected', 'description']),
BootstrapMixin,
AddRemoveTagsForm,
CustomFieldModelBulkEditForm
):
@@ -871,7 +870,6 @@ class ConsolePortBulkEditForm(
class ConsoleServerPortBulkEditForm(
form_from_model(ConsoleServerPort, ['label', 'type', 'speed', 'mark_connected', 'description']),
BootstrapMixin,
AddRemoveTagsForm,
CustomFieldModelBulkEditForm
):
@@ -890,7 +888,6 @@ class ConsoleServerPortBulkEditForm(
class PowerPortBulkEditForm(
form_from_model(PowerPort, ['label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', 'description']),
BootstrapMixin,
AddRemoveTagsForm,
CustomFieldModelBulkEditForm
):
@@ -909,7 +906,6 @@ class PowerPortBulkEditForm(
class PowerOutletBulkEditForm(
form_from_model(PowerOutlet, ['label', 'type', 'feed_leg', 'power_port', 'mark_connected', 'description']),
BootstrapMixin,
AddRemoveTagsForm,
CustomFieldModelBulkEditForm
):
@@ -948,7 +944,6 @@ class InterfaceBulkEditForm(
'label', 'type', 'parent', 'bridge', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected',
'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
]),
BootstrapMixin,
AddRemoveTagsForm,
CustomFieldModelBulkEditForm
):
@@ -1061,7 +1056,6 @@ class InterfaceBulkEditForm(
class FrontPortBulkEditForm(
form_from_model(FrontPort, ['label', 'type', 'color', 'mark_connected', 'description']),
BootstrapMixin,
AddRemoveTagsForm,
CustomFieldModelBulkEditForm
):
@@ -1076,7 +1070,6 @@ class FrontPortBulkEditForm(
class RearPortBulkEditForm(
form_from_model(RearPort, ['label', 'type', 'color', 'mark_connected', 'description']),
BootstrapMixin,
AddRemoveTagsForm,
CustomFieldModelBulkEditForm
):
@@ -1091,7 +1084,6 @@ class RearPortBulkEditForm(
class DeviceBayBulkEditForm(
form_from_model(DeviceBay, ['label', 'description']),
BootstrapMixin,
AddRemoveTagsForm,
CustomFieldModelBulkEditForm
):
@@ -1106,7 +1098,6 @@ class DeviceBayBulkEditForm(
class InventoryItemBulkEditForm(
form_from_model(InventoryItem, ['label', 'manufacturer', 'part_id', 'description']),
BootstrapMixin,
AddRemoveTagsForm,
CustomFieldModelBulkEditForm
):

View File

@@ -3,7 +3,7 @@ from dcim.models import *
from extras.forms import CustomFieldModelForm
from extras.models import Tag
from tenancy.forms import TenancyForm
from utilities.forms import BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect
from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect
__all__ = (
'ConnectCableToCircuitTerminationForm',
@@ -18,7 +18,7 @@ __all__ = (
)
class ConnectCableToDeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
class ConnectCableToDeviceForm(TenancyForm, CustomFieldModelForm):
"""
Base form for connecting a Cable to a Device component
"""
@@ -171,7 +171,7 @@ class ConnectCableToRearPortForm(ConnectCableToDeviceForm):
)
class ConnectCableToCircuitTerminationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
class ConnectCableToCircuitTerminationForm(TenancyForm, CustomFieldModelForm):
termination_b_provider = DynamicModelChoiceField(
queryset=Provider.objects.all(),
label='Provider',
@@ -217,8 +217,7 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, TenancyForm, CustomFi
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', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit',
@@ -230,7 +229,7 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, TenancyForm, CustomFi
return getattr(self.cleaned_data['termination_b_id'], 'pk', None)
class ConnectCableToPowerFeedForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
class ConnectCableToPowerFeedForm(TenancyForm, CustomFieldModelForm):
termination_b_region = DynamicModelChoiceField(
queryset=Region.objects.all(),
label='Region',
@@ -280,8 +279,7 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, TenancyForm, CustomFieldModelF
required=False
)
class Meta:
model = Cable
class Meta(ConnectCableToDeviceForm.Meta):
fields = [
'termination_b_location', 'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'tenant_group',
'tenant', 'label', 'color', 'length', 'length_unit', 'tags',

View File

@@ -9,7 +9,7 @@ from extras.forms import CustomFieldModelFilterForm, LocalConfigContextFilterFor
from ipam.models import ASN
from tenancy.forms import TenancyFilterForm
from utilities.forms import (
APISelectMultiple, add_blank_choice, BootstrapMixin, ColorField, DynamicModelMultipleChoiceField, StaticSelect,
APISelectMultiple, add_blank_choice, ColorField, DynamicModelMultipleChoiceField, FilterForm, StaticSelect,
StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
)
from wireless.choices import *
@@ -47,15 +47,7 @@ __all__ = (
)
class DeviceComponentFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
field_order = [
'q', 'name', 'label', 'region_id', 'site_group_id', 'site_id',
]
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
class DeviceComponentFilterForm(CustomFieldModelFilterForm):
name = forms.CharField(
required=False
)
@@ -93,25 +85,27 @@ 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'
)
class RegionFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
class RegionFilterForm(CustomFieldModelFilterForm):
model = Region
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
parent_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -121,13 +115,8 @@ class RegionFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
tag = TagFilterField(model)
class SiteGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
class SiteGroupFilterForm(CustomFieldModelFilterForm):
model = SiteGroup
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
parent_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
@@ -137,20 +126,14 @@ class SiteGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
tag = TagFilterField(model)
class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
class SiteFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
model = Site
field_order = ['q', 'status', 'region_id', 'tenant_group_id', 'tenant_id', 'asn_id']
field_groups = [
['q', 'tag'],
['status', 'region_id', 'group_id'],
['tenant_group_id', 'tenant_id'],
['asn_id']
]
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
status = forms.MultipleChoiceField(
choices=SiteStatusChoices,
required=False,
@@ -177,18 +160,13 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterFo
tag = TagFilterField(model)
class LocationFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
class LocationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
model = Location
field_groups = [
['q'],
['region_id', 'site_group_id', 'site_id', 'parent_id'],
['tenant_group_id', 'tenant_id'],
]
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -224,19 +202,13 @@ class LocationFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilt
tag = TagFilterField(model)
class RackRoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
class RackRoleFilterForm(CustomFieldModelFilterForm):
model = RackRole
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
tag = TagFilterField(model)
class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
class RackFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
model = Rack
field_order = ['q', 'region_id', 'site_id', 'location_id', 'status', 'role_id', 'tenant_group_id', 'tenant_id']
field_groups = [
['q', 'tag'],
['region_id', 'site_id', 'location_id'],
@@ -244,11 +216,6 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterFo
['type', 'width', 'serial', 'asset_tag'],
['tenant_group_id', 'tenant_id'],
]
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -306,10 +273,6 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterFo
class RackElevationFilterForm(RackFilterForm):
field_order = [
'q', 'region_id', 'site_id', 'location_id', 'id', 'status', 'role_id', 'tenant_group_id',
'tenant_id',
]
id = DynamicModelMultipleChoiceField(
queryset=Rack.objects.all(),
label=_('Rack'),
@@ -322,20 +285,14 @@ class RackElevationFilterForm(RackFilterForm):
)
class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
class RackReservationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
model = RackReservation
field_order = ['q', 'region_id', 'site_id', 'location_id', 'user_id', 'tenant_group_id', 'tenant_id']
field_groups = [
['q', 'tag'],
['user_id'],
['region_id', 'site_id', 'location_id'],
['tenant_group_id', 'tenant_id'],
]
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -370,28 +327,18 @@ class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldMo
tag = TagFilterField(model)
class ManufacturerFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
class ManufacturerFilterForm(CustomFieldModelFilterForm):
model = Manufacturer
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
tag = TagFilterField(model)
class DeviceTypeFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
class DeviceTypeFilterForm(CustomFieldModelFilterForm):
model = DeviceType
field_groups = [
['q', 'tag'],
['manufacturer_id', 'subdevice_role', 'airflow'],
['console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports'],
]
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
required=False,
@@ -453,23 +400,13 @@ class DeviceTypeFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
tag = TagFilterField(model)
class DeviceRoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
class DeviceRoleFilterForm(CustomFieldModelFilterForm):
model = DeviceRole
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
tag = TagFilterField(model)
class PlatformFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
class PlatformFilterForm(CustomFieldModelFilterForm):
model = Platform
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
required=False,
@@ -479,12 +416,8 @@ class PlatformFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
tag = TagFilterField(model)
class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm):
class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm):
model = Device
field_order = [
'q', 'region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'status', 'role_id', 'tenant_group_id',
'tenant_id', 'manufacturer_id', 'device_type_id', 'asset_tag', 'mac_address', 'has_primary_ip',
]
field_groups = [
['q', 'tag'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id'],
@@ -496,11 +429,6 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt
'power_outlets', 'interfaces', 'pass_through_ports', 'local_context_data',
],
]
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -651,19 +579,13 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt
tag = TagFilterField(model)
class VirtualChassisFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
class VirtualChassisFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
model = VirtualChassis
field_order = ['q', 'region_id', 'site_group_id', 'site_id', 'tenant_group_id', 'tenant_id']
field_groups = [
['q', 'tag'],
['region_id', 'site_group_id', 'site_id'],
['tenant_group_id', 'tenant_id'],
]
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -689,7 +611,7 @@ class VirtualChassisFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldMod
tag = TagFilterField(model)
class CableFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
model = Cable
field_groups = [
['q', 'tag'],
@@ -697,11 +619,6 @@ class CableFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterF
['type', 'status', 'color'],
['tenant_group_id', 'tenant_id'],
]
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -754,17 +671,12 @@ class CableFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterF
tag = TagFilterField(model)
class PowerPanelFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
class PowerPanelFilterForm(CustomFieldModelFilterForm):
model = PowerPanel
field_groups = (
('q', 'tag'),
('region_id', 'site_group_id', 'site_id', 'location_id')
)
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -800,7 +712,7 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
tag = TagFilterField(model)
class PowerFeedFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
class PowerFeedFilterForm(CustomFieldModelFilterForm):
model = PowerFeed
field_groups = [
['q', 'tag'],
@@ -808,11 +720,6 @@ class PowerFeedFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
['power_panel_id', 'rack_id'],
['status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization'],
]
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -895,7 +802,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,
@@ -915,7 +822,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,
@@ -935,7 +842,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,
@@ -950,7 +857,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,
@@ -966,7 +873,7 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
['q', 'tag'],
['name', 'label', 'kind', 'type', 'enabled', 'mgmt_only', 'mac_address', 'wwn'],
['rf_role', 'rf_channel', 'rf_channel_width', 'tx_power'],
['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'],
]
kind = forms.MultipleChoiceField(
choices=InterfaceKindChoices,
@@ -1031,7 +938,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(
@@ -1050,7 +957,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,
@@ -1068,7 +975,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)
@@ -1078,7 +985,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(),
@@ -1105,7 +1012,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
# Connections
#
class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form):
class ConsoleConnectionFilterForm(FilterForm):
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -1132,7 +1039,7 @@ class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form):
)
class PowerConnectionFilterForm(BootstrapMixin, forms.Form):
class PowerConnectionFilterForm(FilterForm):
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -1159,7 +1066,7 @@ class PowerConnectionFilterForm(BootstrapMixin, forms.Form):
)
class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form):
class InterfaceConnectionFilterForm(FilterForm):
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,

View File

@@ -66,7 +66,7 @@ Tagged (All): Implies all VLANs are available (w/optional untagged VLAN)
"""
class RegionForm(BootstrapMixin, CustomFieldModelForm):
class RegionForm(CustomFieldModelForm):
parent = DynamicModelChoiceField(
queryset=Region.objects.all(),
required=False
@@ -84,7 +84,7 @@ class RegionForm(BootstrapMixin, CustomFieldModelForm):
)
class SiteGroupForm(BootstrapMixin, CustomFieldModelForm):
class SiteGroupForm(CustomFieldModelForm):
parent = DynamicModelChoiceField(
queryset=SiteGroup.objects.all(),
required=False
@@ -102,7 +102,7 @@ class SiteGroupForm(BootstrapMixin, CustomFieldModelForm):
)
class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
class SiteForm(TenancyForm, CustomFieldModelForm):
region = DynamicModelChoiceField(
queryset=Region.objects.all(),
required=False
@@ -173,7 +173,7 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
}
class LocationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
class LocationForm(TenancyForm, CustomFieldModelForm):
region = DynamicModelChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -221,7 +221,7 @@ class LocationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
)
class RackRoleForm(BootstrapMixin, CustomFieldModelForm):
class RackRoleForm(CustomFieldModelForm):
slug = SlugField()
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
@@ -235,7 +235,7 @@ class RackRoleForm(BootstrapMixin, CustomFieldModelForm):
]
class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
class RackForm(TenancyForm, CustomFieldModelForm):
region = DynamicModelChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -295,7 +295,7 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
}
class RackReservationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
class RackReservationForm(TenancyForm, CustomFieldModelForm):
region = DynamicModelChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -365,7 +365,7 @@ class RackReservationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
)
class ManufacturerForm(BootstrapMixin, CustomFieldModelForm):
class ManufacturerForm(CustomFieldModelForm):
slug = SlugField()
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
@@ -379,7 +379,7 @@ class ManufacturerForm(BootstrapMixin, CustomFieldModelForm):
]
class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm):
class DeviceTypeForm(CustomFieldModelForm):
manufacturer = DynamicModelChoiceField(
queryset=Manufacturer.objects.all()
)
@@ -418,7 +418,7 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm):
}
class DeviceRoleForm(BootstrapMixin, CustomFieldModelForm):
class DeviceRoleForm(CustomFieldModelForm):
slug = SlugField()
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
@@ -432,7 +432,7 @@ class DeviceRoleForm(BootstrapMixin, CustomFieldModelForm):
]
class PlatformForm(BootstrapMixin, CustomFieldModelForm):
class PlatformForm(CustomFieldModelForm):
manufacturer = DynamicModelChoiceField(
queryset=Manufacturer.objects.all(),
required=False
@@ -455,7 +455,7 @@ class PlatformForm(BootstrapMixin, CustomFieldModelForm):
}
class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
class DeviceForm(TenancyForm, CustomFieldModelForm):
region = DynamicModelChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -637,7 +637,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
self.fields['position'].widget.choices = [(position, f'U{position}')]
class CableForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
class CableForm(TenancyForm, CustomFieldModelForm):
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
@@ -660,7 +660,7 @@ class CableForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
}
class PowerPanelForm(BootstrapMixin, CustomFieldModelForm):
class PowerPanelForm(CustomFieldModelForm):
region = DynamicModelChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -704,7 +704,7 @@ class PowerPanelForm(BootstrapMixin, CustomFieldModelForm):
)
class PowerFeedForm(BootstrapMixin, CustomFieldModelForm):
class PowerFeedForm(CustomFieldModelForm):
region = DynamicModelChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -772,7 +772,7 @@ class PowerFeedForm(BootstrapMixin, CustomFieldModelForm):
# Virtual chassis
#
class VirtualChassisForm(BootstrapMixin, CustomFieldModelForm):
class VirtualChassisForm(CustomFieldModelForm):
master = forms.ModelChoiceField(
queryset=Device.objects.all(),
required=False,
@@ -1005,7 +1005,7 @@ class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm):
# Device components
#
class ConsolePortForm(BootstrapMixin, CustomFieldModelForm):
class ConsolePortForm(CustomFieldModelForm):
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
@@ -1021,7 +1021,7 @@ class ConsolePortForm(BootstrapMixin, CustomFieldModelForm):
}
class ConsoleServerPortForm(BootstrapMixin, CustomFieldModelForm):
class ConsoleServerPortForm(CustomFieldModelForm):
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
@@ -1037,7 +1037,7 @@ class ConsoleServerPortForm(BootstrapMixin, CustomFieldModelForm):
}
class PowerPortForm(BootstrapMixin, CustomFieldModelForm):
class PowerPortForm(CustomFieldModelForm):
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
@@ -1054,7 +1054,7 @@ class PowerPortForm(BootstrapMixin, CustomFieldModelForm):
}
class PowerOutletForm(BootstrapMixin, CustomFieldModelForm):
class PowerOutletForm(CustomFieldModelForm):
power_port = forms.ModelChoiceField(
queryset=PowerPort.objects.all(),
required=False
@@ -1083,7 +1083,7 @@ class PowerOutletForm(BootstrapMixin, CustomFieldModelForm):
)
class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm):
class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm):
parent = DynamicModelChoiceField(
queryset=Interface.objects.all(),
required=False,
@@ -1183,7 +1183,7 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm):
self.fields['tagged_vlans'].widget.add_query_param('available_on_device', device.pk)
class FrontPortForm(BootstrapMixin, CustomFieldModelForm):
class FrontPortForm(CustomFieldModelForm):
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
@@ -1211,7 +1211,7 @@ class FrontPortForm(BootstrapMixin, CustomFieldModelForm):
)
class RearPortForm(BootstrapMixin, CustomFieldModelForm):
class RearPortForm(CustomFieldModelForm):
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
@@ -1228,7 +1228,7 @@ class RearPortForm(BootstrapMixin, CustomFieldModelForm):
}
class DeviceBayForm(BootstrapMixin, CustomFieldModelForm):
class DeviceBayForm(CustomFieldModelForm):
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
@@ -1264,7 +1264,7 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
).exclude(pk=device_bay.device.pk)
class InventoryItemForm(BootstrapMixin, CustomFieldModelForm):
class InventoryItemForm(CustomFieldModelForm):
device = DynamicModelChoiceField(
queryset=Device.objects.all()
)

View File

@@ -35,7 +35,7 @@ __all__ = (
)
class ComponentForm(forms.Form):
class ComponentForm(BootstrapMixin, forms.Form):
"""
Subclass this form when facilitating the creation of one or more device component or component templates based on
a name pattern.
@@ -63,7 +63,7 @@ class ComponentForm(forms.Form):
}, code='label_pattern_mismatch')
class VirtualChassisCreateForm(BootstrapMixin, CustomFieldModelForm):
class VirtualChassisCreateForm(CustomFieldModelForm):
region = DynamicModelChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -118,12 +118,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
@@ -136,7 +142,7 @@ class VirtualChassisCreateForm(BootstrapMixin, CustomFieldModelForm):
# Component templates
#
class ComponentTemplateCreateForm(BootstrapMixin, ComponentForm):
class ComponentTemplateCreateForm(ComponentForm):
"""
Base form for the creation of device component templates (subclassed from ComponentTemplateModel).
"""
@@ -329,7 +335,7 @@ class DeviceBayTemplateCreateForm(ComponentTemplateCreateForm):
# Device components
#
class ComponentCreateForm(BootstrapMixin, CustomFieldsMixin, ComponentForm):
class ComponentCreateForm(CustomFieldsMixin, ComponentForm):
"""
Base form for the creation of device components (models subclassed from ComponentModel).
"""
@@ -459,12 +465,17 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm):
query_params={
'device_id': '$device',
'type': 'lag',
}
},
label='LAG'
)
mac_address = forms.CharField(
required=False,
label='MAC Address'
)
wwn = forms.CharField(
required=False,
label='WWN'
)
mgmt_only = forms.BooleanField(
required=False,
label='Management only',
@@ -497,15 +508,17 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm):
)
untagged_vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(),
required=False
required=False,
label='Untagged VLAN'
)
tagged_vlans = DynamicModelMultipleChoiceField(
queryset=VLAN.objects.all(),
required=False
required=False,
label='Tagged VLANs'
)
field_order = (
'device', 'name_pattern', 'label_pattern', 'type', 'enabled', 'parent', 'bridge', 'lag', 'mtu', 'mac_address',
'description', 'mgmt_only', 'mark_connected', 'rf_role', 'rf_channel', 'rf_channel_frequency',
'wwn', 'description', 'mgmt_only', 'mark_connected', 'rf_role', 'rf_channel', 'rf_channel_frequency',
'rf_channel_width', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags'
)

View File

@@ -1,5 +1,3 @@
# Generated by Django 3.2.8 on 2021-10-19 17:41
from django.db import migrations, models
@@ -32,14 +30,54 @@ class Migration(migrations.Migration):
),
migrations.AlterUniqueTogether(
name='location',
unique_together={('site', 'parent', 'name'), ('site', 'parent', 'slug')},
unique_together=set(),
),
migrations.AlterUniqueTogether(
name='region',
unique_together={('parent', 'slug'), ('parent', 'name')},
migrations.AddConstraint(
model_name='location',
constraint=models.UniqueConstraint(fields=('site', 'parent', 'name'), name='dcim_location_parent_name'),
),
migrations.AlterUniqueTogether(
name='sitegroup',
unique_together={('parent', 'slug'), ('parent', 'name')},
migrations.AddConstraint(
model_name='location',
constraint=models.UniqueConstraint(condition=models.Q(('parent', None)), fields=('site', 'name'), name='dcim_location_name'),
),
migrations.AddConstraint(
model_name='location',
constraint=models.UniqueConstraint(fields=('site', 'parent', 'slug'), name='dcim_location_parent_slug'),
),
migrations.AddConstraint(
model_name='location',
constraint=models.UniqueConstraint(condition=models.Q(('parent', None)), fields=('site', 'slug'), name='dcim_location_slug'),
),
migrations.AddConstraint(
model_name='region',
constraint=models.UniqueConstraint(fields=('parent', 'name'), name='dcim_region_parent_name'),
),
migrations.AddConstraint(
model_name='region',
constraint=models.UniqueConstraint(condition=models.Q(('parent', None)), fields=('name',), name='dcim_region_name'),
),
migrations.AddConstraint(
model_name='region',
constraint=models.UniqueConstraint(fields=('parent', 'slug'), name='dcim_region_parent_slug'),
),
migrations.AddConstraint(
model_name='region',
constraint=models.UniqueConstraint(condition=models.Q(('parent', None)), fields=('slug',), name='dcim_region_slug'),
),
migrations.AddConstraint(
model_name='sitegroup',
constraint=models.UniqueConstraint(fields=('parent', 'name'), name='dcim_sitegroup_parent_name'),
),
migrations.AddConstraint(
model_name='sitegroup',
constraint=models.UniqueConstraint(condition=models.Q(('parent', None)), fields=('name',), name='dcim_sitegroup_name'),
),
migrations.AddConstraint(
model_name='sitegroup',
constraint=models.UniqueConstraint(fields=('parent', 'slug'), name='dcim_sitegroup_parent_slug'),
),
migrations.AddConstraint(
model_name='sitegroup',
constraint=models.UniqueConstraint(condition=models.Q(('parent', None)), fields=('slug',), name='dcim_sitegroup_slug'),
),
]

View File

@@ -0,0 +1,29 @@
from django.db import migrations
OLD_VALUE = '128gfc-sfp28'
NEW_VALUE = '128gfc-qsfp28'
def correct_type(apps, schema_editor):
"""
Correct TYPE_128GFC_QSFP28 interface type.
"""
Interface = apps.get_model('dcim', 'Interface')
InterfaceTemplate = apps.get_model('dcim', 'InterfaceTemplate')
for model in (Interface, InterfaceTemplate):
model.objects.filter(type=OLD_VALUE).update(type=NEW_VALUE)
class Migration(migrations.Migration):
dependencies = [
('dcim', '0141_asn_model'),
]
operations = [
migrations.RunPython(
code=correct_type,
reverse_code=migrations.RunPython.noop
),
]

View File

@@ -0,0 +1,23 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('ipam', '0053_asn_model'),
('dcim', '0142_rename_128gfc_qsfp28'),
]
operations = [
migrations.AlterField(
model_name='device',
name='primary_ip4',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='ipam.ipaddress'),
),
migrations.AlterField(
model_name='device',
name='primary_ip6',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='ipam.ipaddress'),
),
]

View File

@@ -14,7 +14,6 @@ from dcim.utils import decompile_path_node, object_to_path_node, path_node_to_ob
from extras.utils import extras_features
from netbox.models import BigIDModel, PrimaryModel
from utilities.fields import ColorField
from utilities.querysets import RestrictedQuerySet
from utilities.utils import to_meters
from .devices import Device
from .device_components import FrontPort, RearPort
@@ -116,8 +115,6 @@ class Cable(PrimaryModel):
null=True
)
objects = RestrictedQuerySet.as_manager()
class Meta:
ordering = ['pk']
unique_together = (

View File

@@ -7,7 +7,6 @@ from dcim.constants import *
from extras.utils import extras_features
from netbox.models import ChangeLoggedModel
from utilities.fields import ColorField, NaturalOrderingField
from utilities.querysets import RestrictedQuerySet
from utilities.ordering import naturalize_interface
from .device_components import (
ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, PowerOutlet, PowerPort, RearPort,
@@ -50,8 +49,6 @@ class ComponentTemplateModel(ChangeLoggedModel):
blank=True
)
objects = RestrictedQuerySet.as_manager()
class Meta:
abstract = True

View File

@@ -16,7 +16,6 @@ from netbox.models import PrimaryModel
from utilities.fields import ColorField, NaturalOrderingField
from utilities.mptt import TreeManager
from utilities.ordering import naturalize_interface
from utilities.querysets import RestrictedQuerySet
from utilities.query_functions import CollateAsChar
from wireless.choices import *
from wireless.utils import get_channel_attr
@@ -65,8 +64,6 @@ class ComponentModel(PrimaryModel):
blank=True
)
objects = RestrictedQuerySet.as_manager()
class Meta:
abstract = True
@@ -189,15 +186,23 @@ class PathEndpoint(models.Model):
abstract = True
def trace(self):
if self._path is None:
return []
origin = self
path = []
# Construct the complete path
path = [self, *self._path.get_path()]
while (len(path) + 1) % 3:
# Pad to ensure we have complete three-tuples (e.g. for paths that end at a non-connected FrontPort)
path.append(None)
path.append(self._path.destination)
while origin is not None:
if origin._path is None:
break
path.extend([origin, *origin._path.get_path()])
while (len(path) + 1) % 3:
# Pad to ensure we have complete three-tuples (e.g. for paths that end at a non-connected FrontPort)
path.append(None)
path.append(origin._path.destination)
# Check for bridge interface to continue the trace
origin = getattr(origin._path.destination, 'bridge', None)
# Return the path as a list of three-tuples (A termination, cable, B termination)
return list(zip(*[iter(path)] * 3))

View File

@@ -18,7 +18,6 @@ from netbox.config import ConfigItem
from netbox.models import OrganizationalModel, PrimaryModel
from utilities.choices import ColorChoices
from utilities.fields import ColorField, NaturalOrderingField
from utilities.querysets import RestrictedQuerySet
from .device_components import *
@@ -59,8 +58,6 @@ class Manufacturer(OrganizationalModel):
to='tenancy.ContactAssignment'
)
objects = RestrictedQuerySet.as_manager()
class Meta:
ordering = ['name']
@@ -137,8 +134,6 @@ class DeviceType(PrimaryModel):
blank=True
)
objects = RestrictedQuerySet.as_manager()
clone_fields = [
'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow',
]
@@ -379,8 +374,6 @@ class DeviceRole(OrganizationalModel):
blank=True,
)
objects = RestrictedQuerySet.as_manager()
class Meta:
ordering = ['name']
@@ -431,8 +424,6 @@ class Platform(OrganizationalModel):
blank=True
)
objects = RestrictedQuerySet.as_manager()
class Meta:
ordering = ['name']
@@ -549,7 +540,7 @@ class Device(PrimaryModel, ConfigContextModel):
primary_ip4 = models.OneToOneField(
to='ipam.IPAddress',
on_delete=models.SET_NULL,
related_name='primary_ip4_for',
related_name='+',
blank=True,
null=True,
verbose_name='Primary IPv4'
@@ -557,7 +548,7 @@ class Device(PrimaryModel, ConfigContextModel):
primary_ip6 = models.OneToOneField(
to='ipam.IPAddress',
on_delete=models.SET_NULL,
related_name='primary_ip6_for',
related_name='+',
blank=True,
null=True,
verbose_name='Primary IPv6'
@@ -613,7 +604,9 @@ class Device(PrimaryModel, ConfigContextModel):
)
def __str__(self):
if self.name:
if self.name and self.asset_tag:
return f'{self.name} ({self.asset_tag})'
elif self.name:
return self.name
elif self.virtual_chassis:
return f'{self.virtual_chassis.name}:{self.vc_position} ({self.pk})'
@@ -896,8 +889,6 @@ class VirtualChassis(PrimaryModel):
blank=True
)
objects = RestrictedQuerySet.as_manager()
class Meta:
ordering = ['name']
verbose_name_plural = 'virtual chassis'

View File

@@ -8,7 +8,6 @@ from dcim.choices import *
from dcim.constants import *
from extras.utils import extras_features
from netbox.models import PrimaryModel
from utilities.querysets import RestrictedQuerySet
from utilities.validators import ExclusionValidator
from .device_components import LinkTermination, PathEndpoint
@@ -49,8 +48,6 @@ class PowerPanel(PrimaryModel):
to='extras.ImageAttachment'
)
objects = RestrictedQuerySet.as_manager()
class Meta:
ordering = ['site', 'name']
unique_together = ['site', 'name']
@@ -131,8 +128,6 @@ class PowerFeed(PrimaryModel, PathEndpoint, LinkTermination):
blank=True
)
objects = RestrictedQuerySet.as_manager()
clone_fields = [
'power_panel', 'rack', 'status', 'type', 'mark_connected', 'supply', 'phase', 'voltage', 'amperage',
'max_utilization', 'available_power',

View File

@@ -18,7 +18,6 @@ from netbox.config import get_config
from netbox.models import OrganizationalModel, PrimaryModel
from utilities.choices import ColorChoices
from utilities.fields import ColorField, NaturalOrderingField
from utilities.querysets import RestrictedQuerySet
from utilities.utils import array_to_string
from .device_components import PowerOutlet, PowerPort
from .devices import Device
@@ -56,8 +55,6 @@ class RackRole(OrganizationalModel):
blank=True,
)
objects = RestrictedQuerySet.as_manager()
class Meta:
ordering = ['name']
@@ -190,8 +187,6 @@ class Rack(PrimaryModel):
to='extras.ImageAttachment'
)
objects = RestrictedQuerySet.as_manager()
clone_fields = [
'site', 'location', 'tenant', 'status', 'role', 'type', 'width', 'u_height', 'desc_units', 'outer_width',
'outer_depth', 'outer_unit',
@@ -471,8 +466,6 @@ class RackReservation(PrimaryModel):
max_length=200
)
objects = RestrictedQuerySet.as_manager()
class Meta:
ordering = ['created', 'pk']

View File

@@ -11,7 +11,6 @@ from dcim.fields import ASNField
from extras.utils import extras_features
from netbox.models import NestedGroupModel, PrimaryModel
from utilities.fields import NaturalOrderingField
from utilities.querysets import RestrictedQuerySet
__all__ = (
'Location',
@@ -63,11 +62,41 @@ class Region(NestedGroupModel):
)
class Meta:
unique_together = (
('parent', 'name'),
('parent', 'slug'),
constraints = (
models.UniqueConstraint(
fields=('parent', 'name'),
name='dcim_region_parent_name'
),
models.UniqueConstraint(
fields=('name',),
name='dcim_region_name',
condition=Q(parent=None)
),
models.UniqueConstraint(
fields=('parent', 'slug'),
name='dcim_region_parent_slug'
),
models.UniqueConstraint(
fields=('slug',),
name='dcim_region_slug',
condition=Q(parent=None)
),
)
def validate_unique(self, exclude=None):
if self.parent is None:
regions = Region.objects.exclude(pk=self.pk)
if regions.filter(name=self.name, parent__isnull=True).exists():
raise ValidationError({
'name': 'A region with this name already exists.'
})
if regions.filter(slug=self.slug, parent__isnull=True).exists():
raise ValidationError({
'name': 'A region with this slug already exists.'
})
super().validate_unique(exclude=exclude)
def get_absolute_url(self):
return reverse('dcim:region', args=[self.pk])
@@ -120,11 +149,41 @@ class SiteGroup(NestedGroupModel):
)
class Meta:
unique_together = (
('parent', 'name'),
('parent', 'slug'),
constraints = (
models.UniqueConstraint(
fields=('parent', 'name'),
name='dcim_sitegroup_parent_name'
),
models.UniqueConstraint(
fields=('name',),
name='dcim_sitegroup_name',
condition=Q(parent=None)
),
models.UniqueConstraint(
fields=('parent', 'slug'),
name='dcim_sitegroup_parent_slug'
),
models.UniqueConstraint(
fields=('slug',),
name='dcim_sitegroup_slug',
condition=Q(parent=None)
),
)
def validate_unique(self, exclude=None):
if self.parent is None:
site_groups = SiteGroup.objects.exclude(pk=self.pk)
if site_groups.filter(name=self.name, parent__isnull=True).exists():
raise ValidationError({
'name': 'A site group with this name already exists.'
})
if site_groups.filter(slug=self.slug, parent__isnull=True).exists():
raise ValidationError({
'name': 'A site group with this slug already exists.'
})
super().validate_unique(exclude=exclude)
def get_absolute_url(self):
return reverse('dcim:sitegroup', args=[self.pk])
@@ -259,8 +318,6 @@ class Site(PrimaryModel):
to='extras.ImageAttachment'
)
objects = RestrictedQuerySet.as_manager()
clone_fields = [
'status', 'region', 'group', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email',
@@ -338,10 +395,40 @@ class Location(NestedGroupModel):
class Meta:
ordering = ['site', 'name']
unique_together = ([
('site', 'parent', 'name'),
('site', 'parent', 'slug'),
])
constraints = (
models.UniqueConstraint(
fields=('site', 'parent', 'name'),
name='dcim_location_parent_name'
),
models.UniqueConstraint(
fields=('site', 'name'),
name='dcim_location_name',
condition=Q(parent=None)
),
models.UniqueConstraint(
fields=('site', 'parent', 'slug'),
name='dcim_location_parent_slug'
),
models.UniqueConstraint(
fields=('site', 'slug'),
name='dcim_location_slug',
condition=Q(parent=None)
),
)
def validate_unique(self, exclude=None):
if self.parent is None:
locations = Location.objects.exclude(pk=self.pk)
if locations.filter(name=self.name, site=self.site, parent__isnull=True).exists():
raise ValidationError({
"name": f"A location with this name in site {self.site} already exists."
})
if locations.filter(slug=self.slug, site=self.site, parent__isnull=True).exists():
raise ValidationError({
"name": f"A location with this slug in site {self.site} already exists."
})
super().validate_unique(exclude=exclude)
def get_absolute_url(self):
return reverse('dcim:location', args=[self.pk])

View File

@@ -478,15 +478,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 (a Cable or WirelessLink)
if connector is not None:

View File

@@ -49,6 +49,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.
@@ -467,6 +475,12 @@ class BaseInterfaceTable(BaseTable):
orderable=False,
verbose_name='IP Addresses'
)
fhrp_groups = tables.TemplateColumn(
accessor=Accessor('fhrp_group_assignments'),
template_code=INTERFACE_FHRPGROUPS,
orderable=False,
verbose_name='FHRP Groups'
)
untagged_vlan = tables.Column(linkify=True)
tagged_vlans = TemplateColumn(
template_code=INTERFACE_TAGGED_VLANS,
@@ -501,14 +515,14 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable
'pk', 'id', 'name', 'device', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn',
'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description',
'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection',
'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans',
'tags', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans',
)
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
class DeviceInterfaceTable(InterfaceTable):
name = tables.TemplateColumn(
template_code='<i class="mdi mdi-{% if record.mgmt_only %}wrench{% elif record.is_lag %}drag-horizontal-variant'
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'),
@@ -534,9 +548,9 @@ class DeviceInterfaceTable(InterfaceTable):
model = Interface
fields = (
'pk', 'id', 'name', 'label', 'enabled', 'type', 'parent', 'bridge', 'lag', 'mgmt_only', 'mtu', 'mode',
'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_width', 'tx_power', 'description',
'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection',
'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', 'actions',
'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer',
'connection', 'tags', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'actions',
)
order_by = ('name',)
default_columns = (
@@ -544,7 +558,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,
}
@@ -663,7 +677,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

View File

@@ -75,12 +75,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', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type',
'width', 'u_height', 'comments', 'device_count', 'get_utilization', 'get_power_utilization', 'tags',
'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',

View File

@@ -40,17 +40,21 @@ 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>
"""
INTERFACE_FHRPGROUPS = """
<div class="table-badge-group">
{% for assignment in value.all %}
<a href="{{ assignment.group.get_absolute_url }}">{{ assignment.group.get_protocol_display }}: {{ assignment.group.group_id }}</a>
{% endfor %}
</div>
"""

View File

@@ -595,6 +595,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'),
@@ -606,14 +612,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,
},
]
@@ -1044,14 +1053,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,
},
]
@@ -1083,14 +1095,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,
},
]
@@ -1150,6 +1165,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'),
@@ -1161,14 +1182,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,
},
]
@@ -1548,7 +1572,7 @@ class ConnectedDeviceTest(APITestCase):
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

@@ -1420,10 +1420,10 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_serial(self):
params = {'serial': 'ABC'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'serial': 'abc'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'serial': ['ABC', 'DEF']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'serial': ['abc', 'def']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_has_primary_ip(self):
params = {'has_primary_ip': 'true'}
@@ -2073,6 +2073,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'),
@@ -2197,6 +2202,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]}

View File

@@ -157,6 +157,7 @@ class RegionView(generic.ObjectView):
parent__in=instance.get_descendants(include_self=True)
)
child_regions_table = tables.RegionTable(child_regions)
child_regions_table.columns.hide('actions')
sites = Site.objects.restrict(request.user, 'view').filter(
region=instance
@@ -241,6 +242,7 @@ class SiteGroupView(generic.ObjectView):
parent__in=instance.get_descendants(include_self=True)
)
child_groups_table = tables.SiteGroupTable(child_groups)
child_groups_table.columns.hide('actions')
sites = Site.objects.restrict(request.user, 'view').filter(
group=instance
@@ -310,6 +312,7 @@ class SiteView(generic.ObjectView):
def get_extra_context(self, request, instance):
stats = {
'location_count': Location.objects.restrict(request.user, 'view').filter(site=instance).count(),
'rack_count': Rack.objects.restrict(request.user, 'view').filter(site=instance).count(),
'device_count': Device.objects.restrict(request.user, 'view').filter(site=instance).count(),
'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(site=instance).count(),

View File

@@ -27,11 +27,14 @@ class ConfigRevisionAdmin(admin.ModelAdmin):
('Pagination', {
'fields': ('PAGINATE_COUNT', 'MAX_PAGE_SIZE'),
}),
('Validation', {
'fields': ('CUSTOM_VALIDATORS',),
}),
('NAPALM', {
'fields': ('NAPALM_USERNAME', 'NAPALM_PASSWORD', 'NAPALM_TIMEOUT', 'NAPALM_ARGS'),
}),
('Miscellaneous', {
'fields': ('MAINTENANCE_MODE', 'MAPS_URL'),
'fields': ('MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'MAPS_URL'),
}),
('Config Revision', {
'fields': ('comment',),

View File

@@ -150,7 +150,7 @@ class ImageAttachmentSerializer(ValidatedModelSerializer):
model = ImageAttachment
fields = [
'id', 'url', 'display', 'content_type', 'object_id', 'parent', 'name', 'image', 'image_height',
'image_width', 'created',
'image_width', 'created', 'last_updated',
]
def validate(self, data):

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

@@ -28,6 +28,10 @@ __all__ = (
class WebhookFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
content_types = ContentTypeFilter()
http_method = django_filters.MultipleChoiceFilter(
choices=WebhookHttpMethodChoices
@@ -40,30 +44,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(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()
@@ -71,6 +126,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

@@ -4,9 +4,7 @@ from django.contrib.contenttypes.models import ContentType
from extras.choices import *
from extras.models import *
from extras.utils import FeatureQuery
from utilities.forms import (
BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorField, ContentTypeChoiceField, StaticSelect,
)
from utilities.forms import BulkEditForm, BulkEditNullBooleanSelect, ColorField, ContentTypeChoiceField, StaticSelect
__all__ = (
'ConfigContextBulkEditForm',
@@ -19,7 +17,7 @@ __all__ = (
)
class CustomFieldBulkEditForm(BootstrapMixin, BulkEditForm):
class CustomFieldBulkEditForm(BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=CustomField.objects.all(),
widget=forms.MultipleHiddenInput
@@ -39,7 +37,7 @@ class CustomFieldBulkEditForm(BootstrapMixin, BulkEditForm):
nullable_fields = []
class CustomLinkBulkEditForm(BootstrapMixin, BulkEditForm):
class CustomLinkBulkEditForm(BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=CustomLink.objects.all(),
widget=forms.MultipleHiddenInput
@@ -66,7 +64,7 @@ class CustomLinkBulkEditForm(BootstrapMixin, BulkEditForm):
nullable_fields = []
class ExportTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
class ExportTemplateBulkEditForm(BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=ExportTemplate.objects.all(),
widget=forms.MultipleHiddenInput
@@ -97,7 +95,7 @@ class ExportTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
nullable_fields = ['description', 'mime_type', 'file_extension']
class WebhookBulkEditForm(BootstrapMixin, BulkEditForm):
class WebhookBulkEditForm(BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Webhook.objects.all(),
widget=forms.MultipleHiddenInput
@@ -140,7 +138,7 @@ class WebhookBulkEditForm(BootstrapMixin, BulkEditForm):
nullable_fields = ['secret', 'conditions', 'ca_file_path']
class TagBulkEditForm(BootstrapMixin, BulkEditForm):
class TagBulkEditForm(BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Tag.objects.all(),
widget=forms.MultipleHiddenInput
@@ -157,7 +155,7 @@ class TagBulkEditForm(BootstrapMixin, BulkEditForm):
nullable_fields = ['description']
class ConfigContextBulkEditForm(BootstrapMixin, BulkEditForm):
class ConfigContextBulkEditForm(BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=ConfigContext.objects.all(),
widget=forms.MultipleHiddenInput
@@ -181,7 +179,7 @@ class ConfigContextBulkEditForm(BootstrapMixin, BulkEditForm):
]
class JournalEntryBulkEditForm(BootstrapMixin, BulkEditForm):
class JournalEntryBulkEditForm(BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=JournalEntry.objects.all(),
widget=forms.MultipleHiddenInput

View File

@@ -4,7 +4,7 @@ from django.db.models import Q
from extras.choices import *
from extras.models import *
from utilities.forms import BulkEditForm, CSVModelForm
from utilities.forms import BootstrapMixin, BulkEditForm, CSVModelForm, FilterForm
__all__ = (
'CustomFieldModelCSVForm',
@@ -52,7 +52,7 @@ class CustomFieldsMixin:
self.custom_fields.append(field_name)
class CustomFieldModelForm(CustomFieldsMixin, forms.ModelForm):
class CustomFieldModelForm(BootstrapMixin, CustomFieldsMixin, forms.ModelForm):
"""
Extend ModelForm to include custom field support.
"""
@@ -105,7 +105,7 @@ class CustomFieldModelBulkEditForm(BulkEditForm):
self.custom_fields.append(cf.name)
class CustomFieldModelFilterForm(forms.Form):
class CustomFieldModelFilterForm(FilterForm):
def __init__(self, *args, **kwargs):

View File

@@ -9,9 +9,8 @@ from extras.models import *
from extras.utils import FeatureQuery
from tenancy.models import Tenant, TenantGroup
from utilities.forms import (
add_blank_choice, APISelectMultiple, BootstrapMixin, ContentTypeChoiceField,
ContentTypeMultipleChoiceField, DateTimePicker, DynamicModelMultipleChoiceField, StaticSelect,
StaticSelectMultiple, BOOLEAN_WITH_BLANK_CHOICES,
add_blank_choice, APISelectMultiple, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DateTimePicker,
DynamicModelMultipleChoiceField, FilterForm, StaticSelect, StaticSelectMultiple, BOOLEAN_WITH_BLANK_CHOICES,
)
from virtualization.models import Cluster, ClusterGroup
@@ -28,17 +27,12 @@ __all__ = (
)
class CustomFieldFilterForm(BootstrapMixin, forms.Form):
class CustomFieldFilterForm(FilterForm):
field_groups = [
['q'],
['type', 'content_types'],
['weight', 'required'],
]
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_fields'),
@@ -61,16 +55,11 @@ class CustomFieldFilterForm(BootstrapMixin, forms.Form):
)
class CustomLinkFilterForm(BootstrapMixin, forms.Form):
class CustomLinkFilterForm(FilterForm):
field_groups = [
['q'],
['content_type', 'weight', 'new_window'],
]
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
content_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_fields'),
@@ -87,16 +76,11 @@ class CustomLinkFilterForm(BootstrapMixin, forms.Form):
)
class ExportTemplateFilterForm(BootstrapMixin, forms.Form):
class ExportTemplateFilterForm(FilterForm):
field_groups = [
['q'],
['content_type', 'mime_type', 'file_extension', 'as_attachment'],
]
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
content_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_fields'),
@@ -117,17 +101,12 @@ class ExportTemplateFilterForm(BootstrapMixin, forms.Form):
)
class WebhookFilterForm(BootstrapMixin, forms.Form):
class WebhookFilterForm(FilterForm):
field_groups = [
['q'],
['content_types', 'http_method', 'enabled'],
['type_create', 'type_update', 'type_delete'],
]
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_fields'),
@@ -165,12 +144,8 @@ class WebhookFilterForm(BootstrapMixin, forms.Form):
)
class TagFilterForm(BootstrapMixin, forms.Form):
class TagFilterForm(FilterForm):
model = Tag
q = forms.CharField(
required=False,
label=_('Search')
)
content_type_id = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()),
required=False,
@@ -178,7 +153,7 @@ class TagFilterForm(BootstrapMixin, forms.Form):
)
class ConfigContextFilterForm(BootstrapMixin, forms.Form):
class ConfigContextFilterForm(FilterForm):
field_groups = [
['q', 'tag'],
['region_id', 'site_group_id', 'site_id'],
@@ -186,11 +161,6 @@ class ConfigContextFilterForm(BootstrapMixin, forms.Form):
['cluster_group_id', 'cluster_id'],
['tenant_group_id', 'tenant_id']
]
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -270,18 +240,13 @@ class LocalConfigContextFilterForm(forms.Form):
)
class JournalEntryFilterForm(BootstrapMixin, forms.Form):
class JournalEntryFilterForm(FilterForm):
model = JournalEntry
field_groups = [
['q'],
['created_before', 'created_after', 'created_by_id'],
['assigned_object_type_id', 'kind']
]
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
created_after = forms.DateTimeField(
required=False,
label=_('After'),
@@ -317,18 +282,13 @@ class JournalEntryFilterForm(BootstrapMixin, forms.Form):
)
class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
class ObjectChangeFilterForm(FilterForm):
model = ObjectChange
field_groups = [
['q'],
['time_before', 'time_after', 'action'],
['user_id', 'changed_object_type_id'],
]
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
time_after = forms.DateTimeField(
required=False,
label=_('After'),

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

@@ -10,12 +10,14 @@ from django.utils import timezone
from packaging import version
from extras.models import ObjectChange
from netbox.config import Config
class Command(BaseCommand):
help = "Perform nightly housekeeping tasks. (This command can be run at any time.)"
def handle(self, *args, **options):
config = Config()
# Clear expired authentication sessions (essentially replicating the `clearsessions` command)
if options['verbosity']:
@@ -37,10 +39,10 @@ class Command(BaseCommand):
# Delete expired ObjectRecords
if options['verbosity']:
self.stdout.write("[*] Checking for expired changelog records")
if settings.CHANGELOG_RETENTION:
cutoff = timezone.now() - timedelta(days=settings.CHANGELOG_RETENTION)
if config.CHANGELOG_RETENTION:
cutoff = timezone.now() - timedelta(days=config.CHANGELOG_RETENTION)
if options['verbosity'] >= 2:
self.stdout.write(f"\tRetention period: {settings.CHANGELOG_RETENTION} days")
self.stdout.write(f"\tRetention period: {config.CHANGELOG_RETENTION} days")
self.stdout.write(f"\tCut-off time: {cutoff}")
expired_records = ObjectChange.objects.filter(time__lt=cutoff).count()
if expired_records:
@@ -58,7 +60,7 @@ class Command(BaseCommand):
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})"
f"\tSkipping: No retention period specified (CHANGELOG_RETENTION = {config.CHANGELOG_RETENTION})"
)
# Check for new releases (if enabled)

View File

@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '0064_configrevision'),
]
operations = [
migrations.AddField(
model_name='imageattachment',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
]

View File

@@ -0,0 +1,18 @@
import django.core.validators
from django.db import migrations, models
import re
class Migration(migrations.Migration):
dependencies = [
('extras', '0065_imageattachment_change_logging'),
]
operations = [
migrations.AlterField(
model_name='customfield',
name='name',
field=models.CharField(max_length=50, unique=True, validators=[django.core.validators.RegexValidator(flags=re.RegexFlag['IGNORECASE'], message='Only alphanumeric characters and underscores are allowed.', regex='^[a-z0-9_]+$')]),
),
]

View File

@@ -22,6 +22,12 @@ from utilities.querysets import RestrictedQuerySet
from utilities.validators import validate_regex
__all__ = (
'CustomField',
'CustomFieldManager',
)
class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
use_in_migrations = True
@@ -33,7 +39,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,
@@ -49,7 +55,14 @@ class CustomField(ChangeLoggedModel):
name = models.CharField(
max_length=50,
unique=True,
help_text='Internal field name'
help_text='Internal field name',
validators=(
RegexValidator(
regex=r'^[a-z0-9_]+$',
message="Only alphanumeric characters and underscores are allowed.",
flags=re.IGNORECASE
),
)
)
label = models.CharField(
max_length=50,

View File

@@ -35,7 +35,7 @@ __all__ = (
)
@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
@@ -125,8 +125,6 @@ class Webhook(ChangeLoggedModel):
'Leave blank to use the system defaults.'
)
objects = RestrictedQuerySet.as_manager()
class Meta:
ordering = ('name',)
unique_together = ('payload_url', 'type_create', 'type_update', 'type_delete',)
@@ -179,7 +177,7 @@ class Webhook(ChangeLoggedModel):
return json.dumps(context, cls=JSONEncoder)
@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
@@ -222,8 +220,6 @@ class CustomLink(ChangeLoggedModel):
help_text="Force link to open in a new window"
)
objects = RestrictedQuerySet.as_manager()
class Meta:
ordering = ['group_name', 'weight', 'name']
@@ -234,7 +230,7 @@ class CustomLink(ChangeLoggedModel):
return reverse('extras:customlink', args=[self.pk])
@extras_features('webhooks')
@extras_features('webhooks', 'export_templates')
class ExportTemplate(ChangeLoggedModel):
content_type = models.ForeignKey(
to=ContentType,
@@ -268,8 +264,6 @@ class ExportTemplate(ChangeLoggedModel):
help_text="Download file as attachment"
)
objects = RestrictedQuerySet.as_manager()
class Meta:
ordering = ['content_type', 'name']
unique_together = [
@@ -323,7 +317,8 @@ class ExportTemplate(ChangeLoggedModel):
return response
class ImageAttachment(BigIDModel):
@extras_features('webhooks')
class ImageAttachment(ChangeLoggedModel):
"""
An uploaded image which is associated with an object.
"""
@@ -347,12 +342,15 @@ class ImageAttachment(BigIDModel):
max_length=50,
blank=True
)
# ChangeLoggingMixin.created is a DateField
created = models.DateTimeField(
auto_now_add=True
)
objects = RestrictedQuerySet.as_manager()
clone_fields = ('content_type', 'object_id')
class Meta:
ordering = ('name', 'pk') # name may be non-unique
@@ -394,6 +392,9 @@ class ImageAttachment(BigIDModel):
except tuple(expected_exceptions):
return None
def to_objectchange(self, action):
return super().to_objectchange(action, related_object=self.parent)
@extras_features('webhooks')
class JournalEntry(ChangeLoggedModel):
@@ -427,8 +428,6 @@ class JournalEntry(ChangeLoggedModel):
)
comments = models.TextField()
objects = RestrictedQuerySet.as_manager()
class Meta:
ordering = ('-created',)
verbose_name_plural = 'journal entries'

View File

@@ -7,14 +7,13 @@ from extras.utils import extras_features
from netbox.models import BigIDModel, ChangeLoggedModel
from utilities.choices import ColorChoices
from utilities.fields import ColorField
from utilities.querysets import RestrictedQuerySet
#
# Tags
#
@extras_features('webhooks')
@extras_features('webhooks', 'export_templates')
class Tag(ChangeLoggedModel, TagBase):
color = ColorField(
default=ColorChoices.COLOR_GREY
@@ -24,8 +23,6 @@ class Tag(ChangeLoggedModel, TagBase):
blank=True,
)
objects = RestrictedQuerySet.as_manager()
class Meta:
ordering = ['name']

View File

@@ -3,6 +3,7 @@ import json
import logging
import os
import pkgutil
import sys
import traceback
from collections import OrderedDict
@@ -477,6 +478,10 @@ def get_scripts(use_names=False):
# 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

View File

@@ -1,17 +1,20 @@
import importlib
import logging
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
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 extras.validators import CustomValidator
from netbox import thread_locals
from netbox.config import get_config
from netbox.request_context import get_request
from netbox.signals import post_clean
from .choices import ObjectChangeActionChoices
from .models import ConfigRevision, CustomField, ObjectChange
from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook
#
# Change logging/webhooks
#
@@ -20,10 +23,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 +40,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 +69,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 +84,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 +101,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()
@@ -157,9 +166,21 @@ m2m_changed.connect(handle_cf_removed_obj_types, sender=CustomField.content_type
@receiver(post_clean)
def run_custom_validators(sender, instance, **kwargs):
config = get_config()
model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
validators = settings.CUSTOM_VALIDATORS.get(model_name, [])
validators = config.CUSTOM_VALIDATORS.get(model_name, [])
for validator in validators:
# Loading a validator class by dotted path
if type(validator) is str:
module, cls = validator.rsplit('.', 1)
validator = getattr(importlib.import_module(module), cls)()
# Constructing a new instance on the fly from a ruleset
elif type(validator) is dict:
validator = CustomValidator(validator)
validator(instance)

View File

@@ -73,7 +73,7 @@ class ChangeLogViewTest(ModelViewTestCase):
site = Site(name='Site 1', slug='site-1')
site.save()
tags = create_tags('Tag 1', 'Tag 2', 'Tag 3')
site.tags.set('Tag 1', 'Tag 2')
site.tags.set(['Tag 1', 'Tag 2'])
form_data = {
'name': 'Site X',
@@ -117,7 +117,7 @@ class ChangeLogViewTest(ModelViewTestCase):
)
site.save()
create_tags('Tag 1', 'Tag 2')
site.tags.set('Tag 1', 'Tag 2')
site.tags.set(['Tag 1', 'Tag 2'])
request = {
'path': self._get_url('delete', instance=site),
@@ -310,7 +310,7 @@ class ChangeLogAPITest(APITestCase):
}
)
site.save()
site.tags.set(*Tag.objects.all()[:2])
site.tags.set(Tag.objects.all()[:2])
self.assertEqual(ObjectChange.objects.count(), 0)
self.add_permissions('dcim.delete_site')
url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})

View File

@@ -119,3 +119,38 @@ class CustomValidatorTest(TestCase):
@override_settings(CUSTOM_VALIDATORS={'dcim.site': [custom_validator]})
def test_custom_valid(self):
Site(name='foo', slug='foo').clean()
class CustomValidatorConfigTest(TestCase):
@override_settings(
CUSTOM_VALIDATORS={
'dcim.site': [
{'name': {'min_length': 5}}
]
}
)
def test_plain_data(self):
"""
Test custom validator configuration using plain data (as opposed to a CustomValidator
class)
"""
with self.assertRaises(ValidationError):
Site(name='abcd', slug='abcd').clean()
Site(name='abcde', slug='abcde').clean()
@override_settings(
CUSTOM_VALIDATORS={
'dcim.site': (
'extras.tests.test_customvalidator.MyValidator',
)
}
)
def test_dotted_path(self):
"""
Test custom validator configuration using a dotted path (string) reference to a
CustomValidator class.
"""
Site(name='foo', slug='foo').clean()
with self.assertRaises(ValidationError):
Site(name='bar', slug='bar').clean()

View File

@@ -542,8 +542,8 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
site = Site.objects.create(name='Site 1', slug='site-1')
provider = Provider.objects.create(name='Provider 1', slug='provider-1')
site.tags.set(tags[0])
provider.tags.set(tags[1])
site.tags.set([tags[0]])
provider.tags.set([tags[1]])
def test_name(self):
params = {'name': ['Tag 1', 'Tag 2']}

View File

@@ -123,7 +123,7 @@ class WebhookTest(APITestCase):
def test_enqueue_webhook_update(self):
site = Site.objects.create(name='Site 1', slug='site-1')
site.tags.set(*Tag.objects.filter(name__in=['Foo', 'Bar']))
site.tags.set(Tag.objects.filter(name__in=['Foo', 'Bar']))
# Update an object via the REST API
data = {
@@ -159,7 +159,7 @@ class WebhookTest(APITestCase):
)
Site.objects.bulk_create(sites)
for site in sites:
site.tags.set(*Tag.objects.filter(name__in=['Foo', 'Bar']))
site.tags.set(Tag.objects.filter(name__in=['Foo', 'Bar']))
# Update three objects via the REST API
data = [
@@ -205,7 +205,7 @@ class WebhookTest(APITestCase):
def test_enqueue_webhook_delete(self):
site = Site.objects.create(name='Site 1', slug='site-1')
site.tags.set(*Tag.objects.filter(name__in=['Foo', 'Bar']))
site.tags.set(Tag.objects.filter(name__in=['Foo', 'Bar']))
# Delete an object via the REST API
url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
@@ -231,7 +231,7 @@ class WebhookTest(APITestCase):
)
Site.objects.bulk_create(sites)
for site in sites:
site.tags.set(*Tag.objects.filter(name__in=['Foo', 'Bar']))
site.tags.set(Tag.objects.filter(name__in=['Foo', 'Bar']))
# Delete three objects via the REST API
data = [

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
@@ -475,11 +475,7 @@ class ImageAttachmentEditView(generic.ObjectEditView):
def alter_obj(self, instance, request, args, kwargs):
if not instance.pk:
# Assign the parent object based on URL kwargs
try:
app_label, model = request.GET.get('content_type').split('.')
except (AttributeError, ValueError):
raise Http404("Content type not specified")
content_type = get_object_or_404(ContentType, app_label=app_label, model=model)
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
@@ -758,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

@@ -1,185 +0,0 @@
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.db import transaction
from django.shortcuts import get_object_or_404
from django_pglocks import advisory_lock
from drf_yasg.utils import swagger_auto_schema
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.response import Response
from ipam.models import *
from netbox.config import get_config
from utilities.constants import ADVISORY_LOCK_KEYS
from . import serializers
class AvailablePrefixesMixin:
@swagger_auto_schema(method='get', responses={200: serializers.AvailablePrefixSerializer(many=True)})
@swagger_auto_schema(method='post', responses={201: serializers.PrefixSerializer(many=False)})
@action(detail=True, url_path='available-prefixes', methods=['get', 'post'])
@advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes'])
def available_prefixes(self, request, pk=None):
"""
A convenience method for returning available child prefixes within a parent.
The advisory lock decorator uses a PostgreSQL advisory lock to prevent this API from being
invoked in parallel, which results in a race condition where multiple insertions can occur.
"""
prefix = get_object_or_404(self.queryset, pk=pk)
available_prefixes = prefix.get_available_prefixes()
if request.method == 'POST':
# Validate Requested Prefixes' length
serializer = serializers.PrefixLengthSerializer(
data=request.data if isinstance(request.data, list) else [request.data],
many=True,
context={
'request': request,
'prefix': prefix,
}
)
if not serializer.is_valid():
return Response(
serializer.errors,
status=status.HTTP_400_BAD_REQUEST
)
requested_prefixes = serializer.validated_data
# Allocate prefixes to the requested objects based on availability within the parent
for i, requested_prefix in enumerate(requested_prefixes):
# Find the first available prefix equal to or larger than the requested size
for available_prefix in available_prefixes.iter_cidrs():
if requested_prefix['prefix_length'] >= available_prefix.prefixlen:
allocated_prefix = '{}/{}'.format(available_prefix.network, requested_prefix['prefix_length'])
requested_prefix['prefix'] = allocated_prefix
requested_prefix['vrf'] = prefix.vrf.pk if prefix.vrf else None
break
else:
return Response(
{
"detail": "Insufficient space is available to accommodate the requested prefix size(s)"
},
status=status.HTTP_204_NO_CONTENT
)
# Remove the allocated prefix from the list of available prefixes
available_prefixes.remove(allocated_prefix)
# Initialize the serializer with a list or a single object depending on what was requested
context = {'request': request}
if isinstance(request.data, list):
serializer = serializers.PrefixSerializer(data=requested_prefixes, many=True, context=context)
else:
serializer = serializers.PrefixSerializer(data=requested_prefixes[0], context=context)
# Create the new Prefix(es)
if serializer.is_valid():
try:
with transaction.atomic():
created = serializer.save()
self._validate_objects(created)
except ObjectDoesNotExist:
raise PermissionDenied()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
else:
serializer = serializers.AvailablePrefixSerializer(available_prefixes.iter_cidrs(), many=True, context={
'request': request,
'vrf': prefix.vrf,
})
return Response(serializer.data)
class AvailableIPsMixin:
parent_model = Prefix
@swagger_auto_schema(method='get', responses={200: serializers.AvailableIPSerializer(many=True)})
@swagger_auto_schema(method='post', responses={201: serializers.AvailableIPSerializer(many=True)},
request_body=serializers.AvailableIPSerializer(many=True))
@action(detail=True, url_path='available-ips', methods=['get', 'post'], queryset=IPAddress.objects.all())
@advisory_lock(ADVISORY_LOCK_KEYS['available-ips'])
def available_ips(self, request, pk=None):
"""
A convenience method for returning available IP addresses within a Prefix or IPRange. By default, the number of
IPs returned will be equivalent to PAGINATE_COUNT. An arbitrary limit (up to MAX_PAGE_SIZE, if set) may be
passed, however results will not be paginated.
The advisory lock decorator uses a PostgreSQL advisory lock to prevent this API from being
invoked in parallel, which results in a race condition where multiple insertions can occur.
"""
parent = get_object_or_404(self.parent_model.objects.restrict(request.user), pk=pk)
# Create the next available IP
if request.method == 'POST':
# Normalize to a list of objects
requested_ips = request.data if isinstance(request.data, list) else [request.data]
# Determine if the requested number of IPs is available
available_ips = parent.get_available_ips()
if available_ips.size < len(requested_ips):
return Response(
{
"detail": f"An insufficient number of IP addresses are available within {parent} "
f"({len(requested_ips)} requested, {len(available_ips)} available)"
},
status=status.HTTP_204_NO_CONTENT
)
# Assign addresses from the list of available IPs and copy VRF assignment from the parent
available_ips = iter(available_ips)
for requested_ip in requested_ips:
requested_ip['address'] = f'{next(available_ips)}/{parent.mask_length}'
requested_ip['vrf'] = parent.vrf.pk if parent.vrf else None
# Initialize the serializer with a list or a single object depending on what was requested
context = {'request': request}
if isinstance(request.data, list):
serializer = serializers.IPAddressSerializer(data=requested_ips, many=True, context=context)
else:
serializer = serializers.IPAddressSerializer(data=requested_ips[0], context=context)
# Create the new IP address(es)
if serializer.is_valid():
try:
with transaction.atomic():
created = serializer.save()
self._validate_objects(created)
except ObjectDoesNotExist:
raise PermissionDenied()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
# Determine the maximum number of IPs to return
else:
config = get_config()
PAGINATE_COUNT = config.PAGINATE_COUNT
MAX_PAGE_SIZE = config.MAX_PAGE_SIZE
try:
limit = int(request.query_params.get('limit', PAGINATE_COUNT))
except ValueError:
limit = PAGINATE_COUNT
if MAX_PAGE_SIZE:
limit = min(limit, MAX_PAGE_SIZE)
# Calculate available IPs within the parent
ip_list = []
for index, ip in enumerate(parent.get_available_ips(), start=1):
ip_list.append(ip)
if index == limit:
break
serializer = serializers.AvailableIPSerializer(ip_list, many=True, context={
'request': request,
'parent': parent,
'vrf': parent.vrf,
})
return Response(serializer.data)

View File

@@ -1,4 +1,7 @@
from django.urls import path
from netbox.api import OrderedDefaultRouter
from ipam.models import IPRange, Prefix
from . import views
@@ -42,4 +45,23 @@ router.register('vlans', views.VLANViewSet)
router.register('services', views.ServiceViewSet)
app_name = 'ipam-api'
urlpatterns = router.urls
urlpatterns = [
path(
'ip-ranges/<int:pk>/available-ips/',
views.IPRangeAvailableIPAddressesView.as_view(),
name='iprange-available-ips'
),
path(
'prefixes/<int:pk>/available-prefixes/',
views.AvailablePrefixesView.as_view(),
name='prefix-available-prefixes'
),
path(
'prefixes/<int:pk>/available-ips/',
views.PrefixAvailableIPAddressesView.as_view(),
name='prefix-available-ips'
),
]
urlpatterns += router.urls

View File

@@ -1,12 +1,23 @@
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.db import transaction
from django_pglocks import advisory_lock
from django.shortcuts import get_object_or_404
from drf_yasg.utils import swagger_auto_schema
from rest_framework import status
from rest_framework.response import Response
from rest_framework.routers import APIRootView
from rest_framework.views import APIView
from dcim.models import Site
from extras.api.views import CustomFieldModelViewSet
from ipam import filtersets
from ipam.models import *
from netbox.api.views import ModelViewSet
from netbox.api.views import ModelViewSet, ObjectValidationMixin
from netbox.config import get_config
from utilities.constants import ADVISORY_LOCK_KEYS
from utilities.utils import count_related
from . import mixins, serializers
from . import serializers
class IPAMRootView(APIRootView):
@@ -18,7 +29,7 @@ class IPAMRootView(APIRootView):
#
# ASNs
# Viewsets
#
class ASNViewSet(CustomFieldModelViewSet):
@@ -27,10 +38,6 @@ class ASNViewSet(CustomFieldModelViewSet):
filterset_class = filtersets.ASNFilterSet
#
# VRFs
#
class VRFViewSet(CustomFieldModelViewSet):
queryset = VRF.objects.prefetch_related('tenant').prefetch_related(
'import_targets', 'export_targets', 'tags'
@@ -42,20 +49,12 @@ class VRFViewSet(CustomFieldModelViewSet):
filterset_class = filtersets.VRFFilterSet
#
# Route targets
#
class RouteTargetViewSet(CustomFieldModelViewSet):
queryset = RouteTarget.objects.prefetch_related('tenant').prefetch_related('tags')
serializer_class = serializers.RouteTargetSerializer
filterset_class = filtersets.RouteTargetFilterSet
#
# RIRs
#
class RIRViewSet(CustomFieldModelViewSet):
queryset = RIR.objects.annotate(
aggregate_count=count_related(Aggregate, 'rir')
@@ -64,20 +63,12 @@ class RIRViewSet(CustomFieldModelViewSet):
filterset_class = filtersets.RIRFilterSet
#
# Aggregates
#
class AggregateViewSet(CustomFieldModelViewSet):
queryset = Aggregate.objects.prefetch_related('rir').prefetch_related('tags')
serializer_class = serializers.AggregateSerializer
filterset_class = filtersets.AggregateFilterSet
#
# Roles
#
class RoleViewSet(CustomFieldModelViewSet):
queryset = Role.objects.annotate(
prefix_count=count_related(Prefix, 'role'),
@@ -87,11 +78,7 @@ class RoleViewSet(CustomFieldModelViewSet):
filterset_class = filtersets.RoleFilterSet
#
# Prefixes
#
class PrefixViewSet(mixins.AvailableIPsMixin, mixins.AvailablePrefixesMixin, CustomFieldModelViewSet):
class PrefixViewSet(CustomFieldModelViewSet):
queryset = Prefix.objects.prefetch_related(
'site', 'vrf__tenant', 'tenant', 'vlan', 'role', 'tags'
)
@@ -106,11 +93,7 @@ class PrefixViewSet(mixins.AvailableIPsMixin, mixins.AvailablePrefixesMixin, Cus
return super().get_serializer_class()
#
# IP ranges
#
class IPRangeViewSet(mixins.AvailableIPsMixin, CustomFieldModelViewSet):
class IPRangeViewSet(CustomFieldModelViewSet):
queryset = IPRange.objects.prefetch_related('vrf', 'role', 'tenant', 'tags')
serializer_class = serializers.IPRangeSerializer
filterset_class = filtersets.IPRangeFilterSet
@@ -118,10 +101,6 @@ class IPRangeViewSet(mixins.AvailableIPsMixin, CustomFieldModelViewSet):
parent_model = IPRange # AvailableIPsMixin
#
# IP addresses
#
class IPAddressViewSet(CustomFieldModelViewSet):
queryset = IPAddress.objects.prefetch_related(
'vrf__tenant', 'tenant', 'nat_inside', 'nat_outside', 'tags', 'assigned_object'
@@ -130,14 +109,11 @@ class IPAddressViewSet(CustomFieldModelViewSet):
filterset_class = filtersets.IPAddressFilterSet
#
# FHRP groups
#
class FHRPGroupViewSet(CustomFieldModelViewSet):
queryset = FHRPGroup.objects.prefetch_related('ip_addresses', 'tags')
serializer_class = serializers.FHRPGroupSerializer
filterset_class = filtersets.FHRPGroupFilterSet
brief_prefetch_fields = ('ip_addresses',)
class FHRPGroupAssignmentViewSet(CustomFieldModelViewSet):
@@ -146,10 +122,6 @@ class FHRPGroupAssignmentViewSet(CustomFieldModelViewSet):
filterset_class = filtersets.FHRPGroupAssignmentFilterSet
#
# VLAN groups
#
class VLANGroupViewSet(CustomFieldModelViewSet):
queryset = VLANGroup.objects.annotate(
vlan_count=count_related(VLAN, 'group')
@@ -158,10 +130,6 @@ class VLANGroupViewSet(CustomFieldModelViewSet):
filterset_class = filtersets.VLANGroupFilterSet
#
# VLANs
#
class VLANViewSet(CustomFieldModelViewSet):
queryset = VLAN.objects.prefetch_related(
'site', 'group', 'tenant', 'role', 'tags'
@@ -172,13 +140,190 @@ class VLANViewSet(CustomFieldModelViewSet):
filterset_class = filtersets.VLANFilterSet
#
# Services
#
class ServiceViewSet(ModelViewSet):
queryset = Service.objects.prefetch_related(
'device', 'virtual_machine', 'tags', 'ipaddresses'
)
serializer_class = serializers.ServiceSerializer
filterset_class = filtersets.ServiceFilterSet
#
# Views
#
class AvailablePrefixesView(ObjectValidationMixin, APIView):
queryset = Prefix.objects.all()
@swagger_auto_schema(responses={200: serializers.AvailablePrefixSerializer(many=True)})
def get(self, request, pk):
prefix = get_object_or_404(Prefix.objects.restrict(request.user), pk=pk)
available_prefixes = prefix.get_available_prefixes()
serializer = serializers.AvailablePrefixSerializer(available_prefixes.iter_cidrs(), many=True, context={
'request': request,
'vrf': prefix.vrf,
})
return Response(serializer.data)
@swagger_auto_schema(
request_body=serializers.PrefixLengthSerializer,
responses={201: serializers.PrefixSerializer(many=True)}
)
@advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes'])
def post(self, request, pk):
self.queryset = self.queryset.restrict(request.user, 'add')
prefix = get_object_or_404(Prefix.objects.restrict(request.user), pk=pk)
available_prefixes = prefix.get_available_prefixes()
# Validate Requested Prefixes' length
serializer = serializers.PrefixLengthSerializer(
data=request.data if isinstance(request.data, list) else [request.data],
many=True,
context={
'request': request,
'prefix': prefix,
}
)
if not serializer.is_valid():
return Response(
serializer.errors,
status=status.HTTP_400_BAD_REQUEST
)
requested_prefixes = serializer.validated_data
# Allocate prefixes to the requested objects based on availability within the parent
for i, requested_prefix in enumerate(requested_prefixes):
# Find the first available prefix equal to or larger than the requested size
for available_prefix in available_prefixes.iter_cidrs():
if requested_prefix['prefix_length'] >= available_prefix.prefixlen:
allocated_prefix = '{}/{}'.format(available_prefix.network, requested_prefix['prefix_length'])
requested_prefix['prefix'] = allocated_prefix
requested_prefix['vrf'] = prefix.vrf.pk if prefix.vrf else None
break
else:
return Response(
{
"detail": "Insufficient space is available to accommodate the requested prefix size(s)"
},
status=status.HTTP_409_CONFLICT
)
# Remove the allocated prefix from the list of available prefixes
available_prefixes.remove(allocated_prefix)
# Initialize the serializer with a list or a single object depending on what was requested
context = {'request': request}
if isinstance(request.data, list):
serializer = serializers.PrefixSerializer(data=requested_prefixes, many=True, context=context)
else:
serializer = serializers.PrefixSerializer(data=requested_prefixes[0], context=context)
# Create the new Prefix(es)
if serializer.is_valid():
try:
with transaction.atomic():
created = serializer.save()
self._validate_objects(created)
except ObjectDoesNotExist:
raise PermissionDenied()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class AvailableIPAddressesView(ObjectValidationMixin, APIView):
queryset = IPAddress.objects.all()
def get_parent(self, request, pk):
raise NotImplemented()
@swagger_auto_schema(responses={200: serializers.AvailableIPSerializer(many=True)})
def get(self, request, pk):
parent = self.get_parent(request, pk)
config = get_config()
PAGINATE_COUNT = config.PAGINATE_COUNT
MAX_PAGE_SIZE = config.MAX_PAGE_SIZE
try:
limit = int(request.query_params.get('limit', PAGINATE_COUNT))
except ValueError:
limit = PAGINATE_COUNT
if MAX_PAGE_SIZE:
limit = min(limit, MAX_PAGE_SIZE)
# Calculate available IPs within the parent
ip_list = []
for index, ip in enumerate(parent.get_available_ips(), start=1):
ip_list.append(ip)
if index == limit:
break
serializer = serializers.AvailableIPSerializer(ip_list, many=True, context={
'request': request,
'parent': parent,
'vrf': parent.vrf,
})
return Response(serializer.data)
@swagger_auto_schema(
request_body=serializers.AvailableIPSerializer,
responses={201: serializers.IPAddressSerializer(many=True)}
)
@advisory_lock(ADVISORY_LOCK_KEYS['available-ips'])
def post(self, request, pk):
self.queryset = self.queryset.restrict(request.user, 'add')
parent = self.get_parent(request, pk)
# Normalize to a list of objects
requested_ips = request.data if isinstance(request.data, list) else [request.data]
# Determine if the requested number of IPs is available
available_ips = parent.get_available_ips()
if available_ips.size < len(requested_ips):
return Response(
{
"detail": f"An insufficient number of IP addresses are available within {parent} "
f"({len(requested_ips)} requested, {len(available_ips)} available)"
},
status=status.HTTP_409_CONFLICT
)
# Assign addresses from the list of available IPs and copy VRF assignment from the parent
available_ips = iter(available_ips)
for requested_ip in requested_ips:
requested_ip['address'] = f'{next(available_ips)}/{parent.mask_length}'
requested_ip['vrf'] = parent.vrf.pk if parent.vrf else None
# Initialize the serializer with a list or a single object depending on what was requested
context = {'request': request}
if isinstance(request.data, list):
serializer = serializers.IPAddressSerializer(data=requested_ips, many=True, context=context)
else:
serializer = serializers.IPAddressSerializer(data=requested_ips[0], context=context)
# Create the new IP address(es)
if serializer.is_valid():
try:
with transaction.atomic():
created = serializer.save()
self._validate_objects(created)
except ObjectDoesNotExist:
raise PermissionDenied()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class PrefixAvailableIPAddressesView(AvailableIPAddressesView):
def get_parent(self, request, pk):
return get_object_or_404(Prefix.objects.restrict(request.user), pk=pk)
class IPRangeAvailableIPAddressesView(AvailableIPAddressesView):
def get_parent(self, request, pk):
return get_object_or_404(IPRange.objects.restrict(request.user), pk=pk)

View File

@@ -8,8 +8,8 @@ from ipam.models import *
from ipam.models import ASN
from tenancy.models import Tenant
from utilities.forms import (
add_blank_choice, BootstrapMixin, BulkEditNullBooleanSelect, DatePicker, DynamicModelChoiceField, NumericArrayField,
StaticSelect, DynamicModelMultipleChoiceField,
add_blank_choice, BulkEditNullBooleanSelect, DatePicker, DynamicModelChoiceField, NumericArrayField, StaticSelect,
DynamicModelMultipleChoiceField,
)
__all__ = (
@@ -29,7 +29,7 @@ __all__ = (
)
class VRFBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
class VRFBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=VRF.objects.all(),
widget=forms.MultipleHiddenInput()
@@ -54,7 +54,7 @@ class VRFBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEdi
]
class RouteTargetBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
class RouteTargetBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=RouteTarget.objects.all(),
widget=forms.MultipleHiddenInput()
@@ -74,7 +74,7 @@ class RouteTargetBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldMode
]
class RIRBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
class RIRBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=RIR.objects.all(),
widget=forms.MultipleHiddenInput
@@ -92,7 +92,7 @@ class RIRBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEdi
nullable_fields = ['is_private', 'description']
class ASNBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
class ASNBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=ASN.objects.all(),
widget=forms.MultipleHiddenInput()
@@ -124,7 +124,7 @@ class ASNBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEdi
}
class AggregateBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
class AggregateBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Aggregate.objects.all(),
widget=forms.MultipleHiddenInput()
@@ -155,7 +155,7 @@ class AggregateBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelB
}
class RoleBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
class RoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Role.objects.all(),
widget=forms.MultipleHiddenInput
@@ -172,7 +172,7 @@ class RoleBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEd
nullable_fields = ['description']
class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
class PrefixBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Prefix.objects.all(),
widget=forms.MultipleHiddenInput()
@@ -237,7 +237,7 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulk
]
class IPRangeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
class IPRangeBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=IPRange.objects.all(),
widget=forms.MultipleHiddenInput()
@@ -271,7 +271,7 @@ class IPRangeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBul
]
class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
class IPAddressBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=IPAddress.objects.all(),
widget=forms.MultipleHiddenInput()
@@ -315,7 +315,7 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelB
]
class FHRPGroupBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
class FHRPGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=FHRPGroup.objects.all(),
widget=forms.MultipleHiddenInput()
@@ -350,7 +350,7 @@ class FHRPGroupBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelB
nullable_fields = ['auth_type', 'auth_key', 'description']
class VLANGroupBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
class VLANGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=VLANGroup.objects.all(),
widget=forms.MultipleHiddenInput
@@ -368,7 +368,7 @@ class VLANGroupBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelB
nullable_fields = ['site', 'description']
class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
class VLANBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=VLAN.objects.all(),
widget=forms.MultipleHiddenInput()
@@ -420,7 +420,7 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEd
]
class ServiceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
class ServiceBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Service.objects.all(),
widget=forms.MultipleHiddenInput()

View File

@@ -8,10 +8,9 @@ from ipam.constants import *
from ipam.models import *
from ipam.models import ASN
from tenancy.forms import TenancyFilterForm
from tenancy.models import Tenant
from utilities.forms import (
add_blank_choice, BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect,
StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
add_blank_choice, DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect, StaticSelectMultiple,
TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
)
__all__ = (
@@ -39,18 +38,13 @@ IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([
])
class VRFFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
class VRFFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
model = VRF
field_groups = [
['q', 'tag'],
['import_target_id', 'export_target_id'],
['tenant_group_id', 'tenant_id'],
]
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
import_target_id = DynamicModelMultipleChoiceField(
queryset=RouteTarget.objects.all(),
required=False,
@@ -66,18 +60,13 @@ class VRFFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterFor
tag = TagFilterField(model)
class RouteTargetFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
class RouteTargetFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
model = RouteTarget
field_groups = [
['q', 'tag'],
['importing_vrf_id', 'exporting_vrf_id'],
['tenant_group_id', 'tenant_id'],
]
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
importing_vrf_id = DynamicModelMultipleChoiceField(
queryset=VRF.objects.all(),
required=False,
@@ -93,13 +82,8 @@ class RouteTargetFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelF
tag = TagFilterField(model)
class RIRFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
class RIRFilterForm(CustomFieldModelFilterForm):
model = RIR
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
is_private = forms.NullBooleanField(
required=False,
label=_('Private'),
@@ -110,18 +94,13 @@ class RIRFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
tag = TagFilterField(model)
class AggregateFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
class AggregateFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
model = Aggregate
field_groups = [
['q', 'tag'],
['family', 'rir_id'],
['tenant_group_id', 'tenant_id']
]
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
family = forms.ChoiceField(
required=False,
choices=add_blank_choice(IPAddressFamilyChoices),
@@ -137,7 +116,7 @@ class AggregateFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFil
tag = TagFilterField(model)
class ASNFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
class ASNFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
model = ASN
field_groups = [
['q'],
@@ -145,11 +124,6 @@ class ASNFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterFor
['tenant_group_id', 'tenant_id'],
['site_id'],
]
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
rir_id = DynamicModelMultipleChoiceField(
queryset=RIR.objects.all(),
required=False,
@@ -164,17 +138,12 @@ class ASNFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterFor
)
class RoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
class RoleFilterForm(CustomFieldModelFilterForm):
model = Role
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
tag = TagFilterField(model)
class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
class PrefixFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
model = Prefix
field_groups = [
['q', 'tag'],
@@ -183,11 +152,6 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilter
['region_id', 'site_group_id', 'site_id'],
['tenant_group_id', 'tenant_id']
]
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
mask_length__lte = forms.IntegerField(
widget=forms.HiddenInput()
)
@@ -276,18 +240,13 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilter
tag = TagFilterField(model)
class IPRangeFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
class IPRangeFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
model = IPRange
field_groups = [
['q', 'tag'],
['family', 'vrf_id', 'status', 'role_id'],
['tenant_group_id', 'tenant_id'],
]
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
family = forms.ChoiceField(
required=False,
choices=add_blank_choice(IPAddressFamilyChoices),
@@ -316,23 +275,14 @@ class IPRangeFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilte
tag = TagFilterField(model)
class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
class IPAddressFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
model = IPAddress
field_order = [
'q', 'parent', 'family', 'mask_length', 'vrf_id', 'present_in_vrf_id', 'status', 'role',
'assigned_to_interface', 'tenant_group_id', 'tenant_id',
]
field_groups = [
['q', 'tag'],
['parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface'],
['vrf_id', 'present_in_vrf_id'],
['tenant_group_id', 'tenant_id'],
]
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
parent = forms.CharField(
required=False,
widget=forms.TextInput(
@@ -387,18 +337,13 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFil
tag = TagFilterField(model)
class FHRPGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
class FHRPGroupFilterForm(CustomFieldModelFilterForm):
model = FHRPGroup
field_groups = (
('q', 'tag'),
('protocol', 'group_id'),
('auth_type', 'auth_key'),
)
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
protocol = forms.MultipleChoiceField(
choices=FHRPGroupProtocolChoices,
required=False,
@@ -422,17 +367,12 @@ class FHRPGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
tag = TagFilterField(model)
class VLANGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
class VLANGroupFilterForm(CustomFieldModelFilterForm):
field_groups = [
['q', 'tag'],
['region', 'sitegroup', 'site', 'location', 'rack']
]
model = VLANGroup
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
region = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -466,19 +406,14 @@ class VLANGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
tag = TagFilterField(model)
class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
class VLANFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
model = VLAN
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(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -523,20 +458,19 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterFo
label=_('Role'),
fetch_trigger='open'
)
vid = forms.IntegerField(
required=False,
label='VLAN ID'
)
tag = TagFilterField(model)
class ServiceFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
class ServiceFilterForm(CustomFieldModelFilterForm):
model = Service
field_groups = (
('q', 'tag'),
('protocol', 'port'),
)
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
protocol = forms.ChoiceField(
choices=add_blank_choice(ServiceProtocolChoices),
required=False,

View File

@@ -37,7 +37,7 @@ __all__ = (
)
class VRFForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
class VRFForm(TenancyForm, CustomFieldModelForm):
import_targets = DynamicModelMultipleChoiceField(
queryset=RouteTarget.objects.all(),
required=False
@@ -70,7 +70,7 @@ class VRFForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
}
class RouteTargetForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
class RouteTargetForm(TenancyForm, CustomFieldModelForm):
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
@@ -87,7 +87,7 @@ class RouteTargetForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
)
class RIRForm(BootstrapMixin, CustomFieldModelForm):
class RIRForm(CustomFieldModelForm):
slug = SlugField()
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
@@ -101,7 +101,7 @@ class RIRForm(BootstrapMixin, CustomFieldModelForm):
]
class AggregateForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
class AggregateForm(TenancyForm, CustomFieldModelForm):
rir = DynamicModelChoiceField(
queryset=RIR.objects.all(),
label='RIR'
@@ -129,7 +129,7 @@ class AggregateForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
}
class ASNForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
class ASNForm(TenancyForm, CustomFieldModelForm):
rir = DynamicModelChoiceField(
queryset=RIR.objects.all(),
label='RIR',
@@ -173,7 +173,7 @@ class ASNForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
return instance
class RoleForm(BootstrapMixin, CustomFieldModelForm):
class RoleForm(CustomFieldModelForm):
slug = SlugField()
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
@@ -187,7 +187,7 @@ class RoleForm(BootstrapMixin, CustomFieldModelForm):
]
class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
class PrefixForm(TenancyForm, CustomFieldModelForm):
vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(),
required=False,
@@ -262,7 +262,7 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
}
class IPRangeForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
class IPRangeForm(TenancyForm, CustomFieldModelForm):
vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(),
required=False,
@@ -291,7 +291,7 @@ class IPRangeForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
}
class IPAddressForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
class IPAddressForm(TenancyForm, CustomFieldModelForm):
device = DynamicModelChoiceField(
queryset=Device.objects.all(),
required=False,
@@ -321,6 +321,11 @@ class IPAddressForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
'virtual_machine_id': '$virtual_machine'
}
)
fhrpgroup = DynamicModelChoiceField(
queryset=FHRPGroup.objects.all(),
required=False,
label='FHRP Group'
)
vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(),
required=False,
@@ -428,6 +433,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
initial['interface'] = instance.assigned_object
elif type(instance.assigned_object) is VMInterface:
initial['vminterface'] = instance.assigned_object
elif type(instance.assigned_object) is FHRPGroup:
initial['fhrpgroup'] = instance.assigned_object
if instance.nat_inside:
nat_inside_parent = instance.nat_inside.assigned_object
if type(nat_inside_parent) is Interface:
@@ -444,8 +451,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
# Initialize primary_for_parent if IP address is already assigned
if self.instance.pk and self.instance.assigned_object:
parent = self.instance.assigned_object.parent_object
if (
parent = getattr(self.instance.assigned_object, 'parent_object', None)
if parent and (
self.instance.address.version == 4 and parent.primary_ip4_id == self.instance.pk or
self.instance.address.version == 6 and parent.primary_ip6_id == self.instance.pk
):
@@ -454,10 +461,13 @@ class IPAddressForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
def clean(self):
super().clean()
# Cannot select both a device interface and a VM interface
if self.cleaned_data.get('interface') and self.cleaned_data.get('vminterface'):
raise forms.ValidationError("Cannot select both a device interface and a virtual machine interface")
self.instance.assigned_object = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface')
# Handle object assignment
if self.cleaned_data['interface']:
self.instance.assigned_object = self.cleaned_data['interface']
elif self.cleaned_data['vminterface']:
self.instance.assigned_object = self.cleaned_data['vminterface']
elif self.cleaned_data['fhrpgroup']:
self.instance.assigned_object = self.cleaned_data['fhrpgroup']
# Primary IP assignment is only available if an interface has been assigned.
interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface')
@@ -471,7 +481,7 @@ class IPAddressForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
# Assign/clear this IPAddress as the primary for the associated Device/VirtualMachine.
interface = self.instance.assigned_object
if interface:
if type(interface) in (Interface, VMInterface):
parent = interface.parent_object
if self.cleaned_data['primary_for_parent']:
if ipaddress.address.version == 4:
@@ -489,7 +499,7 @@ class IPAddressForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
return ipaddress
class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
class IPAddressBulkAddForm(TenancyForm, CustomFieldModelForm):
vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(),
required=False,
@@ -523,7 +533,7 @@ class IPAddressAssignForm(BootstrapMixin, forms.Form):
)
class FHRPGroupForm(BootstrapMixin, CustomFieldModelForm):
class FHRPGroupForm(CustomFieldModelForm):
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
@@ -565,9 +575,9 @@ class FHRPGroupForm(BootstrapMixin, CustomFieldModelForm):
vrf=self.cleaned_data['ip_vrf'],
address=self.cleaned_data['ip_address'],
status=self.cleaned_data['ip_status'],
role=FHRP_PROTOCOL_ROLE_MAPPINGS[self.cleaned_data['protocol']],
assigned_object=instance
)
ipaddress.role = FHRP_PROTOCOL_ROLE_MAPPINGS[self.cleaned_data['protocol']]
ipaddress.save()
# Check that the new IPAddress conforms with any assigned object-level permissions
@@ -576,6 +586,22 @@ class FHRPGroupForm(BootstrapMixin, CustomFieldModelForm):
return instance
def clean(self):
ip_vrf = self.cleaned_data.get('ip_vrf')
ip_address = self.cleaned_data.get('ip_address')
ip_status = self.cleaned_data.get('ip_status')
if ip_address:
ip_form = IPAddressForm({
'address': ip_address,
'vrf': ip_vrf,
'status': ip_status,
})
if not ip_form.is_valid():
self.errors.update({
f'ip_{field}': error for field, error in ip_form.errors.items()
})
class FHRPGroupAssignmentForm(BootstrapMixin, forms.ModelForm):
group = DynamicModelChoiceField(
@@ -594,7 +620,7 @@ class FHRPGroupAssignmentForm(BootstrapMixin, forms.ModelForm):
self.fields['group'].widget.add_query_param('related_ip', ipaddress.pk)
class VLANGroupForm(BootstrapMixin, CustomFieldModelForm):
class VLANGroupForm(CustomFieldModelForm):
scope_type = ContentTypeChoiceField(
queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES),
required=False,
@@ -701,7 +727,7 @@ class VLANGroupForm(BootstrapMixin, CustomFieldModelForm):
self.instance.scope_id = None
class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
class VLANForm(TenancyForm, CustomFieldModelForm):
# VLANGroup assignment fields
scope_type = forms.ChoiceField(
choices=(
@@ -782,7 +808,7 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
}
class ServiceForm(BootstrapMixin, CustomFieldModelForm):
class ServiceForm(CustomFieldModelForm):
ports = NumericArrayField(
base_field=forms.IntegerField(
min_value=SERVICE_PORT_MIN,

View File

@@ -8,7 +8,6 @@ from extras.utils import extras_features
from netbox.models import ChangeLoggedModel, PrimaryModel
from ipam.choices import *
from ipam.constants import *
from utilities.querysets import RestrictedQuerySet
__all__ = (
'FHRPGroup',
@@ -47,21 +46,27 @@ class FHRPGroup(PrimaryModel):
to='ipam.IPAddress',
content_type_field='assigned_object_type',
object_id_field='assigned_object_id',
related_query_name='fhrp_group'
related_query_name='fhrpgroup'
)
objects = RestrictedQuerySet.as_manager()
clone_fields = [
'protocol', 'auth_type', 'auth_key'
]
clone_fields = ('protocol', 'auth_type', 'auth_key')
class Meta:
ordering = ['protocol', 'group_id', 'pk']
verbose_name = 'FHRP group'
def __str__(self):
return f'{self.get_protocol_display()} group {self.group_id}'
name = f'{self.get_protocol_display()}: {self.group_id}'
# Append the list of assigned IP addresses to serve as an additional identifier
if self.pk:
ip_addresses = [
str(ip.address) for ip in self.ip_addresses.all()
]
if ip_addresses:
return f"{name} ({', '.join(ip_addresses)})"
return name
def get_absolute_url(self):
return reverse('ipam:fhrpgroup', args=[self.pk])
@@ -89,7 +94,7 @@ class FHRPGroupAssignment(ChangeLoggedModel):
)
)
objects = RestrictedQuerySet.as_manager()
clone_fields = ('interface_type', 'interface_id')
class Meta:
ordering = ('-priority', 'pk')
@@ -98,3 +103,9 @@ class FHRPGroupAssignment(ChangeLoggedModel):
def __str__(self):
return f'{self.interface}: {self.group} ({self.priority})'
def get_absolute_url(self):
# Used primarily for redirection after creating a new assignment
if self.interface:
return self.interface.get_absolute_url()
return None

View File

@@ -18,7 +18,6 @@ from ipam.managers import IPAddressManager
from ipam.querysets import PrefixQuerySet
from ipam.validators import DNSValidator
from netbox.config import get_config
from utilities.querysets import RestrictedQuerySet
from virtualization.models import VirtualMachine
@@ -57,8 +56,6 @@ class RIR(OrganizationalModel):
blank=True
)
objects = RestrictedQuerySet.as_manager()
class Meta:
ordering = ['name']
verbose_name = 'RIR'
@@ -100,8 +97,6 @@ class ASN(PrimaryModel):
null=True
)
objects = RestrictedQuerySet.as_manager()
class Meta:
ordering = ['asn']
verbose_name = 'ASN'
@@ -143,8 +138,6 @@ class Aggregate(PrimaryModel):
blank=True
)
objects = RestrictedQuerySet.as_manager()
clone_fields = [
'rir', 'tenant', 'date_added', 'description',
]
@@ -235,8 +228,6 @@ class Role(OrganizationalModel):
blank=True,
)
objects = RestrictedQuerySet.as_manager()
class Meta:
ordering = ['weight', 'name']
@@ -592,8 +583,6 @@ class IPRange(PrimaryModel):
blank=True
)
objects = RestrictedQuerySet.as_manager()
clone_fields = [
'vrf', 'tenant', 'status', 'role', 'description',
]

View File

@@ -8,7 +8,6 @@ from extras.utils import extras_features
from ipam.choices import *
from ipam.constants import *
from netbox.models import PrimaryModel
from utilities.querysets import RestrictedQuerySet
from utilities.utils import array_to_string
@@ -65,8 +64,6 @@ class Service(PrimaryModel):
blank=True
)
objects = RestrictedQuerySet.as_manager()
class Meta:
ordering = ('protocol', 'ports', 'pk') # (protocol, port) may be non-unique

View File

@@ -11,7 +11,6 @@ from ipam.choices import *
from ipam.constants import *
from ipam.querysets import VLANQuerySet
from netbox.models import OrganizationalModel, PrimaryModel
from utilities.querysets import RestrictedQuerySet
from virtualization.models import VMInterface
@@ -52,8 +51,6 @@ class VLANGroup(OrganizationalModel):
blank=True
)
objects = RestrictedQuerySet.as_manager()
class Meta:
ordering = ('name', 'pk') # Name may be non-unique
unique_together = [

View File

@@ -4,7 +4,6 @@ from django.urls import reverse
from extras.utils import extras_features
from ipam.constants import *
from netbox.models import PrimaryModel
from utilities.querysets import RestrictedQuerySet
__all__ = (
@@ -58,8 +57,6 @@ class VRF(PrimaryModel):
blank=True
)
objects = RestrictedQuerySet.as_manager()
clone_fields = [
'tenant', 'enforce_unique', 'description',
]
@@ -100,8 +97,6 @@ class RouteTarget(PrimaryModel):
null=True
)
objects = RestrictedQuerySet.as_manager()
class Meta:
ordering = ['name']

View File

@@ -235,6 +235,11 @@ 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'
@@ -259,8 +264,8 @@ class PrefixTable(BaseTable):
class Meta(BaseTable.Meta):
model = Prefix
fields = (
'pk', 'id', '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',
@@ -347,7 +352,7 @@ class IPAddressTable(BaseTable):
verbose_name='NAT (Inside)'
)
assigned = BooleanColumn(
accessor='assigned_object',
accessor='assigned_object_id',
linkify=True,
verbose_name='Assigned'
)

View File

@@ -96,7 +96,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

View File

@@ -289,7 +289,7 @@ class PrefixTest(APIViewTestCases.APIViewTestCase):
vrf = VRF.objects.create(name='VRF 1')
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/28'), vrf=vrf, is_pool=True)
url = reverse('ipam-api:prefix-available-prefixes', kwargs={'pk': prefix.pk})
self.add_permissions('ipam.add_prefix')
self.add_permissions('ipam.view_prefix', 'ipam.add_prefix')
# Create four available prefixes with individual requests
prefixes_to_be_created = [
@@ -311,7 +311,7 @@ class PrefixTest(APIViewTestCases.APIViewTestCase):
# Try to create one more prefix
response = self.client.post(url, {'prefix_length': 30}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertHttpStatus(response, status.HTTP_409_CONFLICT)
self.assertIn('detail', response.data)
# Try to create invalid prefix type
@@ -337,7 +337,7 @@ class PrefixTest(APIViewTestCases.APIViewTestCase):
{'prefix_length': 30, 'description': 'Prefix 5'},
]
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertHttpStatus(response, status.HTTP_409_CONFLICT)
self.assertIn('detail', response.data)
# Verify that no prefixes were created (the entire /28 is still available)
@@ -391,7 +391,7 @@ class PrefixTest(APIViewTestCases.APIViewTestCase):
# Try to create one more IP
response = self.client.post(url, {}, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertHttpStatus(response, status.HTTP_409_CONFLICT)
self.assertIn('detail', response.data)
def test_create_multiple_available_ips(self):
@@ -406,7 +406,7 @@ class PrefixTest(APIViewTestCases.APIViewTestCase):
# Try to create nine IPs (only eight are available)
data = [{'description': f'Test IP {i}'} for i in range(1, 10)] # 9 IPs
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertHttpStatus(response, status.HTTP_409_CONFLICT)
self.assertIn('detail', response.data)
# Create all eight available IPs in a single request
@@ -488,7 +488,7 @@ class IPRangeTest(APIViewTestCases.APIViewTestCase):
# Try to create one more IP
response = self.client.post(url, {}, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertHttpStatus(response, status.HTTP_409_CONFLICT)
self.assertIn('detail', response.data)
def test_create_multiple_available_ips(self):
@@ -505,7 +505,7 @@ class IPRangeTest(APIViewTestCases.APIViewTestCase):
# Try to create nine IPs (only eight are available)
data = [{'description': f'Test IP #{i}'} for i in range(1, 10)] # 9 IPs
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertHttpStatus(response, status.HTTP_409_CONFLICT)
self.assertIn('detail', response.data)
# Create all eight available IPs in a single request

View File

@@ -523,9 +523,7 @@ class PrefixIPAddressesView(generic.ObjectView):
def get_extra_context(self, request, instance):
# Find all IPAddresses belonging to this Prefix
ipaddresses = instance.get_child_ips().restrict(request.user, 'view').prefetch_related(
'vrf', 'primary_ip4_for', 'primary_ip6_for'
)
ipaddresses = instance.get_child_ips().restrict(request.user, 'view').prefetch_related('vrf')
# Add available IP addresses to the table if requested
if request.GET.get('show_available', 'true') == 'true':
@@ -604,9 +602,7 @@ class IPRangeIPAddressesView(generic.ObjectView):
def get_extra_context(self, request, instance):
# Find all IPAddresses within this range
ipaddresses = instance.get_child_ips().restrict(request.user, 'view').prefetch_related(
'vrf', 'primary_ip4_for', 'primary_ip6_for'
)
ipaddresses = instance.get_child_ips().restrict(request.user, 'view').prefetch_related('vrf')
# Add available IP addresses to the table if requested
# if request.GET.get('show_available', 'true') == 'true':
@@ -739,6 +735,12 @@ class IPAddressEditView(generic.ObjectEditView):
except (ValueError, VMInterface.DoesNotExist):
pass
elif 'fhrpgroup' in request.GET:
try:
obj.assigned_object = FHRPGroup.objects.get(pk=request.GET['fhrpgroup'])
except (ValueError, FHRPGroup.DoesNotExist):
pass
return obj
@@ -976,11 +978,7 @@ class FHRPGroupAssignmentEditView(generic.ObjectEditView):
def alter_obj(self, instance, request, args, kwargs):
if not instance.pk:
# Assign the interface based on URL kwargs
try:
app_label, model = request.GET.get('interface_type').split('.')
except (AttributeError, ValueError):
raise Http404("Content type not specified")
content_type = get_object_or_404(ContentType, app_label=app_label, model=model)
content_type = get_object_or_404(ContentType, pk=request.GET.get('interface_type'))
instance.interface = get_object_or_404(content_type.model_class(), pk=request.GET.get('interface_id'))
return instance

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

@@ -175,7 +175,7 @@ class PrimaryModelSerializer(CustomFieldModelSerializer):
def _save_tags(self, instance, tags):
if tags:
instance.tags.set(*[t.name for t in tags])
instance.tags.set([t.name for t in tags])
else:
instance.tags.clear()

View File

@@ -123,11 +123,28 @@ class BulkDestroyModelMixin:
self.perform_destroy(obj)
class ObjectValidationMixin:
def _validate_objects(self, instance):
"""
Check that the provided instance or list of instances are matched by the current queryset. This confirms that
any newly created or modified objects abide by the attributes granted by any applicable ObjectPermissions.
"""
if type(instance) is list:
# Check that all instances are still included in the view's queryset
conforming_count = self.queryset.filter(pk__in=[obj.pk for obj in instance]).count()
if conforming_count != len(instance):
raise ObjectDoesNotExist
else:
# Check that the instance is matched by the view's queryset
self.queryset.get(pk=instance.pk)
#
# Viewsets
#
class ModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ModelViewSet_):
class ModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectValidationMixin, ModelViewSet_):
"""
Extend DRF's ModelViewSet to support bulk update and delete functions.
"""
@@ -211,20 +228,6 @@ class ModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ModelViewSet_):
**kwargs
)
def _validate_objects(self, instance):
"""
Check that the provided instance or list of instances are matched by the current queryset. This confirms that
any newly created or modified objects abide by the attributes granted by any applicable ObjectPermissions.
"""
if type(instance) is list:
# Check that all instances are still included in the view's queryset
conforming_count = self.queryset.filter(pk__in=[obj.pk for obj in instance]).count()
if conforming_count != len(instance):
raise ObjectDoesNotExist
else:
# Check that the instance is matched by the view's queryset
self.queryset.get(pk=instance.pk)
def list(self, request, *args, **kwargs):
"""
Overrides ListModelMixin to allow processing ExportTemplates.

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

@@ -94,6 +94,15 @@ PARAMS = (
field=forms.IntegerField
),
# Validation
ConfigParam(
name='CUSTOM_VALIDATORS',
label='Custom validators',
default={},
description="Custom validation rules (JSON)",
field=forms.JSONField
),
# NAPALM
ConfigParam(
name='NAPALM_USERNAME',
@@ -130,6 +139,20 @@ PARAMS = (
description="Enable maintenance mode",
field=forms.BooleanField
),
ConfigParam(
name='GRAPHQL_ENABLED',
label='GraphQL enabled',
default=True,
description="Enable the GraphQL API",
field=forms.BooleanField
),
ConfigParam(
name='CHANGELOG_RETENTION',
label='Changelog retention',
default=90,
description="Days to retain changelog history (set to zero for unlimited)",
field=forms.IntegerField
),
ConfigParam(
name='MAPS_URL',
label='Maps URL',

View File

@@ -76,9 +76,6 @@ ADMINS = [
# BASE_PATH = 'netbox/'
BASE_PATH = ''
# Maximum number of days to retain logged changes. Set to 0 to retain changes indefinitely. (Default: 90)
CHANGELOG_RETENTION = 90
# API Cross-Origin Resource Sharing (CORS) settings. If CORS_ORIGIN_ALLOW_ALL is set to True, all origins will be
# allowed. Otherwise, define a list of allowed origins using either CORS_ORIGIN_WHITELIST or
# CORS_ORIGIN_REGEX_WHITELIST. For more information, see https://github.com/ottoyiu/django-cors-headers
@@ -90,20 +87,6 @@ CORS_ORIGIN_REGEX_WHITELIST = [
# r'^(https?://)?(\w+\.)?example\.com$',
]
# Specify any custom validators here, as a mapping of model to a list of validators classes. Validators should be
# instances of or inherit from CustomValidator.
# from extras.validators import CustomValidator
CUSTOM_VALIDATORS = {
# 'dcim.site': [
# CustomValidator({
# 'name': {
# 'min_length': 10,
# 'regex': r'\d{3}$',
# }
# })
# ],
}
# Set to True to enable server debugging. WARNING: Debugging introduces a substantial performance penalty and may reveal
# sensitive information about your installation. Only enable debugging while performing testing. Never enable debugging
# on a production system.
@@ -129,9 +112,6 @@ EXEMPT_VIEW_PERMISSIONS = [
# 'ipam.prefix',
]
# Enable the GraphQL API
GRAPHQL_ENABLED = True
# HTTP proxies NetBox should use when sending outbound HTTP requests (e.g. for webhooks).
# HTTP_PROXIES = {
# 'http': 'http://10.10.1.10:3128',

View File

@@ -6,6 +6,7 @@ from graphene_django.views import GraphQLView as GraphQLView_
from rest_framework.exceptions import AuthenticationFailed
from netbox.api.authentication import TokenAuthentication
from netbox.config import get_config
class GraphQLView(GraphQLView_):
@@ -15,9 +16,10 @@ class GraphQLView(GraphQLView_):
graphiql_template = 'graphiql.html'
def dispatch(self, request, *args, **kwargs):
config = get_config()
# Enforce GRAPHQL_ENABLED
if not settings.GRAPHQL_ENABLED:
if not config.GRAPHQL_ENABLED:
return HttpResponseNotFound("The GraphQL API is not enabled.")
# Attempt to authenticate the user using a DRF token, if provided

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

View File

@@ -11,6 +11,7 @@ from taggit.managers import TaggableManager
from extras.choices import ObjectChangeActionChoices
from netbox.signals import post_clean
from utilities.mptt import TreeManager
from utilities.querysets import RestrictedQuerySet
from utilities.utils import serialize_object
__all__ = (
@@ -169,6 +170,8 @@ class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, BigIDModel):
"""
Base model for all objects which support change logging.
"""
objects = RestrictedQuerySet.as_manager()
class Meta:
abstract = True
@@ -183,6 +186,8 @@ class PrimaryModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin,
content_type_field='assigned_object_type'
)
objects = RestrictedQuerySet.as_manager()
class Meta:
abstract = True
@@ -251,6 +256,8 @@ class OrganizationalModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidatio
blank=True
)
objects = RestrictedQuerySet.as_manager()
class Meta:
abstract = True
ordering = ('name',)

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

@@ -19,7 +19,7 @@ from netbox.config import PARAMS
# Environment setup
#
VERSION = '3.1-beta1'
VERSION = '3.1.1'
# Hostname
HOSTNAME = platform.node()
@@ -80,11 +80,9 @@ ADMINS = getattr(configuration, 'ADMINS', [])
BASE_PATH = getattr(configuration, 'BASE_PATH', '')
if BASE_PATH:
BASE_PATH = BASE_PATH.strip('/') + '/' # Enforce trailing slash only
CHANGELOG_RETENTION = getattr(configuration, 'CHANGELOG_RETENTION', 90)
CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False)
CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', [])
CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', [])
CUSTOM_VALIDATORS = getattr(configuration, 'CUSTOM_VALIDATORS', {})
DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y')
DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a')
DEBUG = getattr(configuration, 'DEBUG', False)
@@ -92,7 +90,6 @@ DEVELOPER = getattr(configuration, 'DEVELOPER', False)
DOCS_ROOT = getattr(configuration, 'DOCS_ROOT', os.path.join(os.path.dirname(BASE_DIR), 'docs'))
EMAIL = getattr(configuration, 'EMAIL', {})
EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', [])
GRAPHQL_ENABLED = getattr(configuration, 'GRAPHQL_ENABLED', True)
HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', None)
INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1'))
LOGGING = getattr(configuration, 'LOGGING', {})
@@ -427,7 +424,7 @@ EXEMPT_PATHS = (
f'/{BASE_PATH}graphql/',
f'/{BASE_PATH}login/',
f'/{BASE_PATH}oauth/',
f'/{BASE_PATH}metrics/',
f'/{BASE_PATH}metrics',
)
@@ -462,7 +459,7 @@ FILTERS_NULL_CHOICE_VALUE = 'null'
# Django REST framework (API)
#
REST_FRAMEWORK_VERSION = VERSION.rsplit('.', 1)[0] # Use major.minor as API version
REST_FRAMEWORK_VERSION = '.'.join(VERSION.split('-')[0].split('.')[:2]) # Use major.minor as API version
REST_FRAMEWORK = {
'ALLOWED_VERSIONS': [REST_FRAMEWORK_VERSION],
'COERCE_DECIMAL_TO_STRING': False,

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 = {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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