Compare commits

...

210 Commits

Author SHA1 Message Date
Jeremy Stretch
f5356b84f6 Merge pull request #7730 from netbox-community/develop
Release v3.0.9
2021-11-03 09:47:37 -04:00
jeremystretch
1bf100ba15 Release v3.0.9 2021-11-03 09:32:57 -04:00
jeremystretch
7614f423e5 #7612: Use escape() rather than strip_tags() 2021-11-03 08:56:30 -04:00
jeremystretch
318c8b85e9 Fixes #7721: Retain pagination preference when MAX_PAGE_SIZE is zero 2021-11-03 08:25:50 -04:00
jeremystretch
7085fe77da Changelog for #6529 2021-11-03 08:11:38 -04:00
Jeremy Stretch
2e20d7f02b Merge pull request #7677 from netbox-community/6529-command-line-run-scripts
#6529 - Add CLI to run scripts
2021-11-03 08:10:36 -04:00
Steven
831065b5a1 #7717 Missing tags column definition in IP range table (#7724) 2021-11-03 08:05:34 -04:00
Daniel Sheppard
b97167e841 Fix PEP8 error 2021-11-02 21:40:40 -05:00
Daniel Sheppard
19bacc9e23 #6529 - Adjusted the arguments. Fixed documentation 2021-11-02 21:37:11 -05:00
jeremystretch
61b61b1bc0 Fixes #7664: Preserve initial form data when bulk edit validation fails 2021-11-02 17:07:30 -04:00
Daniel Sheppard
7c3318df92 #6529 - Adjusted the arguments. Added documentation 2021-11-02 15:56:42 -05:00
jeremystretch
d0b85586b9 Changelog & cleanup for #6930 2021-11-02 16:24:28 -04:00
Rhys Barrie
cef0d168a5 Closes #6930: Add 'ID' column to object tables (#7673)
* netbox-community/netbox#6930: Add ID column to devices, device types, and components

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

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

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

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

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

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

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

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

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

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

* Exclude ID column from selected tables

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

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

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

* Fix image link in cable.md

* Fix image link in power.md
2021-11-01 09:13:18 -04:00
Rhys Barrie
24d6941cc4 netbox-community/netbox#7668: Add 'View Elevations' button to location detail page 2021-10-29 14:07:02 -04:00
Daniel Sheppard
0a62f75a40 #6529 - Add CLI to run scripts 2021-10-28 15:14:42 -05:00
thatmattlove
a090955918 Fixes #7599: Improve color mode handling 2021-10-27 11:34:21 -07:00
jeremystretch
dfdeac4968 Fixes #7647: Require interface assignment when designating IP address as primary for device/VM during CSV import 2021-10-27 10:20:17 -04:00
jeremystretch
e84f2e3ad2 Fixes #7601: Correct devices count for locations within global search results 2021-10-27 10:10:14 -04:00
jeremystretch
98ca4f5b5a Fixes #7643: Fix circuit assignment when creating multiple terminations simultaneously 2021-10-27 10:02:36 -04:00
jeremystretch
87779b7b88 Fixes #7644: Prevent inadvertent deletion of prior change records when deleting objects (#7333 revisited) 2021-10-27 09:44:15 -04:00
jeremystretch
b56cae24c5 Fixes #7628: Fix load_yaml method for custom scripts 2021-10-27 09:04:18 -04:00
jeremystretch
d48a68317d Fixes #7612: Strip HTML from custom field descriptions 2021-10-27 08:41:28 -04:00
jeremystretch
090df05193 PRVB 2021-10-20 09:59:33 -04:00
Jeremy Stretch
2c161c01c1 Merge pull request #7590 from netbox-community/develop
Release v3.0.8
2021-10-20 09:49:15 -04:00
jeremystretch
fc5a23cc88 Release v3.0.8 2021-10-20 09:31:12 -04:00
jeremystretch
73f2f9fc63 Closes #7551: Add UI field to filter interfaces by kind 2021-10-19 15:57:02 -04:00
jeremystretch
eb4b4a6c8d Closes #7561: Add a utilization column to the IP ranges table 2021-10-19 15:51:39 -04:00
jeremystretch
39430e01de Fixes #7550: Fix rendering of UTF8-encoded data in change records 2021-10-19 15:41:19 -04:00
jeremystretch
96015aa590 Fixes #7582: Fix rendering of CustomLink context data table 2021-10-19 15:31:07 -04:00
jeremystretch
c1720505f3 Fixes #7584: Fix alignment of object identifier under object view 2021-10-19 15:22:22 -04:00
Jeremy Stretch
5c338a90a1 Merge pull request #7566 from PieterL75/patch-1
Fix #7556 : NewVersion showing url
2021-10-19 15:20:58 -04:00
PieterL75
79cee12b1e Updated release notes with #7556 2021-10-19 16:23:05 +02:00
PieterL75
aa5c42683a Fix #7556 : NewVersion showing url 2021-10-18 16:12:23 +02:00
thatmattlove
9c6938e7ae Minor Style Improvement: Fix interface table dropdowns being hidden when opened 2021-10-15 17:45:47 -07:00
thatmattlove
811c21ec7e Minor Style Improvement: Add vertical spacing to Device Type component navigation & fix inconsistent component active color 2021-10-15 17:21:36 -07:00
thatmattlove
84c14aadc7 Fixes #7300: Fix incorrect Device LLDP interface row coloring & improve related JS 2021-10-15 17:07:54 -07:00
thatmattlove
f1f0d9cd0d Fixes #7495: Fix sidenav overlapping elements 2021-10-15 15:02:50 -07:00
jeremystretch
e16942dea5 Fixes #7529: Restore horizontal scrolling for tables in narrow viewports 2021-10-14 13:44:54 -04:00
Jeremy Stretch
12efcec3b0 Merge pull request #7546 from miaow2/7545-webhook-events-status
Fixes #7545: Incorrect display of Events status on webhook page
2021-10-14 13:43:00 -04:00
miaow2
a7b6c40596 Fixing display of webhook types 2021-10-14 20:35:21 +03:00
jeremystretch
b95773938d Fixes #7534: Avoid exception when utilizing "create and add another" twice in succession 2021-10-14 12:24:29 -04:00
jeremystretch
6898ae7106 Fixes #7544: Fix multi-value filtering of custom field objects 2021-10-14 11:36:13 -04:00
jeremystretch
1a4f8c5422 PRVB 2021-10-11 14:42:29 -04:00
Jeremy Stretch
66c4d23119 Merge pull request #7510 from netbox-community/develop
Release v3.0.7
2021-10-08 14:04:34 -04:00
jeremystretch
d66fc8f661 Release v3.0.7 2021-10-08 13:49:15 -04:00
jeremystretch
031876964f #2102: Implement q search filter for device type components 2021-10-08 13:42:43 -04:00
jeremystretch
c63766c4c6 Fix test for #7051 2021-10-07 14:19:29 -04:00
jeremystretch
af6237e12e Fixes #7479: Fix parent interface choices when bulk editing VM interfaces 2021-10-07 13:57:00 -04:00
jeremystretch
00328226ec Fixes #7051: Fix permissions evaluation and improve error handling for connected device REST API endpoint 2021-10-07 13:15:59 -04:00
jeremystretch
b31ba4e9d2 Changelog & UI tweaks for #6879 2021-10-07 12:41:24 -04:00
Jeremy Stretch
4be5d3f9e9 Merge pull request #6960 from candlerb/candlerb/6879-v3
Display device names in front of device front/rear images
2021-10-07 12:35:17 -04:00
jeremystretch
53154746fc Changelog for #7485 2021-10-07 10:40:51 -04:00
Jeremy Stretch
2f4c1b6e8f Merge pull request #7475 from HumanEquivalentUnit/patch-1
Mention data in custom fields, link Jinja2 docs.
2021-10-07 10:38:01 -04:00
Jeremy Stretch
045ec7d3a0 Merge pull request #7486 from alexanderhofstaetter/patch-1
Added "USB Micro AB" combo type to choices
2021-10-07 10:35:40 -04:00
jeremystretch
b73db750e5 Fixes #7471: Correct redirect URL when attaching images via "add another" button 2021-10-07 09:58:42 -04:00
jeremystretch
3f766ffea8 Fixes #7474: Fix AttributeError exception when rendering a report or custom script 2021-10-07 09:37:21 -04:00
Alexander Hofstätter
f28761202f Added "USB Micro AB" combo type to choices 2021-10-07 14:31:54 +02:00
HumanEquivalentUnit
6d1f07df05 Mention data in custom fields, link Jinja2 docs.
Resolves #7367
2021-10-07 00:51:07 +01:00
Brian Candler
eb9f2b36ab Display device names in front of device front/rear images
Fixes #6879
2021-10-06 18:07:28 +00:00
jeremystretch
2bd29127dc PRVB 2021-10-06 14:04:24 -04:00
Jeremy Stretch
3eef6363fd Merge pull request #7465 from netbox-community/develop
Release v3.0.6
2021-10-06 14:02:44 -04:00
jeremystretch
d451f30bfc Release v3.0.6 2021-10-06 13:45:02 -04:00
jeremystretch
105956f8e6 Closes #7464: Improve documentation for executing housekeeping task as a cron job 2021-10-06 13:24:13 -04:00
jeremystretch
39256afb67 Closes #7394: Enable filtering cables by termination type & ID in REST API 2021-10-06 12:06:32 -04:00
jeremystretch
69aaf28b9c Closes #6955: Include type, ID, and slug on object view 2021-10-06 11:23:06 -04:00
jeremystretch
b806220074 Closes #6850: Default to current user when creating journal entries via REST API 2021-10-06 10:56:50 -04:00
jeremystretch
d2bdf4e822 Closes #7462: Include count of assigned virtual machines under platform view 2021-10-06 10:12:44 -04:00
jeremystretch
3ab5682e7a Fixes #7460: Fix filtering connections by site ID 2021-10-06 10:08:56 -04:00
jeremystretch
c0010ec100 Fixes #7459: Pre-populate location data when adding a device to a rack 2021-10-06 10:00:31 -04:00
jeremystretch
6897c5fadd Fixes #7455: Fix site/provider network validation for circuit termination API serializer 2021-10-06 09:11:42 -04:00
jeremystretch
745aa23ed6 Fixes #7458: Correct tenants count label under tenant group view 2021-10-06 08:44:59 -04:00
jeremystretch
9089f5cf67 #7450: Clean up object edit forms 2021-10-05 15:37:49 -04:00
jeremystretch
dd79aae137 #7450: Misc UI cleanup 2021-10-05 15:21:49 -04:00
jeremystretch
26e470f521 #7449: Use lighter color for top-level nav menu items 2021-10-05 14:57:35 -04:00
jeremystretch
a34c8b80e5 #7449: Use original primary color 2021-10-05 14:52:10 -04:00
jeremystretch
854a12982f #7449: Lighten dropdown widget caret color 2021-10-05 14:36:33 -04:00
jeremystretch
cf173d4f50 #7449: Remove color from table header links 2021-10-05 14:16:19 -04:00
jeremystretch
7041486b93 #7449: Remove color from panel headers on home view 2021-10-05 14:03:14 -04:00
jeremystretch
548a8c3be3 #7449: Fix login banner color 2021-10-05 14:01:24 -04:00
jeremystretch
087a018faf Fix changelog for v3.0.5 2021-10-05 12:08:07 -04:00
jeremystretch
e09024e86f Fixes #7446: Fix exception when viewing a large number of child IPs within a prefix 2021-10-05 12:07:03 -04:00
jeremystretch
1757102536 Fixes #7442: Fix missing actions column on user-configured tables 2021-10-05 09:34:30 -04:00
jeremystretch
c262af550d PRVB 2021-10-04 14:18:42 -04:00
Jeremy Stretch
d9c6609b24 Merge pull request #7437 from netbox-community/develop
Release v3.0.5
2021-10-04 14:15:53 -04:00
jeremystretch
339bcb89bb Release v3.0.5 2021-10-04 13:46:34 -04:00
jeremystretch
b5884a5b54 Fixes #7215: Prevent rack elevations from overlapping when higher width is specified 2021-10-04 13:41:16 -04:00
thatmattlove
c818d63043 Fixes #7427: Don't select hidden rows when selecting all in a table 2021-10-04 09:19:18 -07:00
jeremystretch
c9c537a1b9 Fixes #6817: Custom field columns should be removed from tables upon their deletion 2021-10-01 20:22:54 -04:00
jeremystretch
1be748b479 Fixes #6433: Fix bulk editing of child prefixes under aggregate view 2021-10-01 16:21:16 -04:00
jeremystretch
376c776520 Fixes #7425: Housekeeping command should honor zero verbosity 2021-10-01 15:29:22 -04:00
jeremystretch
a1f271d7d9 Fixes #7417: Prevent exception when filtering objects list by invalid tag 2021-10-01 14:07:26 -04:00
jeremystretch
724997cb48 Closes #5925: Always show IP addresses tab under prefix view 2021-10-01 13:39:29 -04:00
jeremystretch
f3fe3f9a18 Closes #6708: Add image attachment support for circuits, power panels 2021-10-01 12:50:51 -04:00
jeremystretch
357a5d1e65 Refactor image attachments panel template 2021-10-01 12:45:41 -04:00
jeremystretch
460e3fd5d6 Introduce a common URL for the creation of image attachments 2021-10-01 12:34:30 -04:00
jeremystretch
257c0afdb5 Closes #6423: Cache rendered REST API specifications 2021-10-01 12:02:04 -04:00
jeremystretch
ed3bc7cdcc Changelog for #7387 2021-10-01 09:30:17 -04:00
Jeremy Stretch
bd181ac84f Merge pull request #7400 from maximumG/7387-order-scripts
Fixes #7387 : possibility to order scripts
2021-10-01 09:20:08 -04:00
jeremystretch
d1f5988db7 Closes #7319: Remove migrations check from upgrade.sh to avoid misleading error messages 2021-10-01 08:57:17 -04:00
jeremystretch
a5b99e7148 Fixes #7412: Fix exception in UI when adding child device to device bay 2021-09-30 12:29:08 -04:00
jeremystretch
114500e7f4 Fixes #7401: Pin jsonschema package to v3.2.0 to fix REST API docs rendering 2021-09-30 11:41:32 -04:00
jeremystretch
d9f178e315 Fixes #7411: Fix exception in UI when adding member devices to virtual chassis 2021-09-30 11:36:16 -04:00
maximumG
7337630704 chore: introduce the script_order notion in the documentation 2021-09-30 09:21:38 +02:00
maximumG
0fdd081869 feat: scripts within a module can now be ordered 2021-09-30 09:17:33 +02:00
jeremystretch
a9761e8dd2 Fixes #7397: Fix AttributeError exception when rendering export template for devices via REST API 2021-09-29 21:09:12 -04:00
jeremystretch
1f1a05dc67 Fixes #6895: Remove errant markup for null values in CSV export 2021-09-29 21:00:45 -04:00
thatmattlove
14b065cf5f Fixes #7373: Improve handling of mismatched server, client, and browser color-mode preferences 2021-09-29 17:44:28 -07:00
jeremystretch
47c3a20fda Correct version number referenced for installation video 2021-09-29 12:40:20 -04:00
jeremystretch
19c984bdab PRVB 2021-09-29 09:46:39 -04:00
Jeremy Stretch
84d83fbd14 Merge pull request #7393 from netbox-community/develop
Release v3.0.4
2021-09-29 09:41:11 -04:00
jeremystretch
965ba3aca1 Release v3.0.4 2021-09-29 09:18:57 -04:00
jeremystretch
38f34ddb28 Fixes #7392: Fix "help" links for custom fields, other models 2021-09-29 09:09:10 -04:00
jeremystretch
6b3e0d3229 Drop pycryptodome as a Python dependency 2021-09-28 17:07:15 -04:00
jeremystretch
854121b6ec Closes #7314: Add SMA 905/906 fiber port types 2021-09-28 16:42:54 -04:00
jeremystretch
047425dadd Closes #7389: Linkify tenant group in tenants list 2021-09-28 16:28:11 -04:00
jeremystretch
8dc0767cdf Changelog for #7365 2021-09-28 16:22:28 -04:00
Jeremy Stretch
a99753fb0f Merge pull request #7366 from shuichiro-makigaki/optimize-prefix-util
Fix #7365: Optimize calculation of prefix utilization
2021-09-28 16:09:27 -04:00
jeremystretch
ad65e06d0a Closes #7252: Validate IP range size does not exceed max supported value 2021-09-28 15:44:41 -04:00
jeremystretch
bfb37d6283 Closes #7022: Add ITA type C (CEE 7/16) power port type 2021-09-28 13:46:54 -04:00
jeremystretch
fd180e480a Changelog & docs cleanup for #6973 2021-09-28 13:40:24 -04:00
Jeremy Stretch
3e5452d9da Merge pull request #7369 from maximumG/6973-order-reports
Fixes #6973:  order reports
2021-09-28 13:34:23 -04:00
jeremystretch
3ec0fe5519 Closes #7372: Link to local docs for model from object add/edit views 2021-09-28 13:23:57 -04:00
jeremystretch
71449b3414 Fixes #7304: Require explicit values for all required choice fields during CSV import 2021-09-28 12:04:54 -04:00
Jeremy Stretch
16f5e233d0 Merge pull request #7382 from netbox-community/refactor-forms
Refactor forms
2021-09-28 11:40:34 -04:00
Daniel Sheppard
abb72868f2 Update changelog for #7374 2021-09-28 09:50:23 -05:00
jeremystretch
13e9d57d68 Merge branch 'develop' into refactor-forms 2021-09-28 10:49:14 -04:00
jeremystretch
833acc3618 Refactor extras forms 2021-09-28 10:44:53 -04:00
jeremystretch
db522f96be Refactor virtualization forms 2021-09-28 10:25:07 -04:00
Daniel Sheppard
df8b76127e Fixes #7374 - Adds query param on position for rack face 2021-09-28 09:06:35 -05:00
jeremystretch
8e849566d5 Refactored IPAM forms 2021-09-28 09:25:12 -04:00
jeremystretch
dba9602c75 Refactor tenancy forms 2021-09-27 17:19:05 -04:00
jeremystretch
9e2364b246 Refactor DCIM forms 2021-09-27 17:09:37 -04:00
jeremystretch
b5aecfeb91 Refactor circuits forms 2021-09-27 15:23:12 -04:00
jeremystretch
c7523ffc67 Fixes #7358: Add missing choices column to custom field CSV import form 2021-09-27 12:28:52 -04:00
jeremystretch
d87d860a57 Remove obsolete TS for a.formaction 2021-09-27 11:41:05 -04:00
jeremystretch
68b1234388 Fixes #7353: Fix bulk creation of device/VM components via list view 2021-09-27 10:36:01 -04:00
jeremystretch
8fda08a1b5 Fixes #7356: Fix display of model documentation when adding device components 2021-09-27 10:09:32 -04:00
jeremystretch
aaba4b534f Fixes #7360: Correct redirection URL after removing child device from device bay 2021-09-27 09:58:03 -04:00
Jeremy Stretch
6d32aa8a88 Merge pull request #7320 from royreznik/feature-6917
Feature-6917 make ip assigned checkmark link to interface
2021-09-27 09:18:24 -04:00
maximumG
0214c388ae add: document how to order reports 2021-09-27 11:02:37 +02:00
maximumG
e443d719d4 feat: reports within a module can now be ordered 2021-09-27 11:02:31 +02:00
Shuichiro MAKIGAKI
d514290688 Fix #7365: Optimize calculation of prefix utilization 2021-09-25 22:28:31 +09:00
royreznik
694bd231e3 Update changelog with #6917 2021-09-23 06:33:26 -07:00
royreznik
8523ad166e Feature-6917 make ip assigned checkmark link to interface 2021-09-23 06:33:26 -07:00
Daniel Sheppard
7ec6b4ebb7 Fixes #7341 - Corrects url in Circuit breadcrumb 2021-09-22 11:48:22 -05:00
jeremystretch
38172b793b Fixes #7294: Fix SVG rendering for cable traces ending at unoccupied front ports 2021-09-21 15:04:37 -04:00
jeremystretch
6bccb6d90b Fixes #7333: Prevent inadvertent deletion of prior change records when deleting objects 2021-09-21 14:24:23 -04:00
jeremystretch
3cf1d6baf4 Closes #7118: Render URL custom fields as hyperlinks in object tables 2021-09-21 14:04:47 -04:00
jeremystretch
2a1718bfc8 Closes #7323: Add serial filter field for racks & devices 2021-09-21 13:53:11 -04:00
jeremystretch
0db4092266 Fixes #7321: Don't overwrite multi-select custom fields during bulk edit 2021-09-21 13:41:33 -04:00
jeremystretch
41dfdc0aaa Fixes #7324: Fix TypeError exception in web UI when filtering objects using single-choice filters 2021-09-21 09:13:26 -04:00
jeremystretch
6bc72109c1 PRVB 2021-09-20 13:19:53 -04:00
Jeremy Stretch
55e9685d30 Merge pull request #7317 from netbox-community/develop
Release v3.0.3
2021-09-20 13:08:32 -04:00
jeremystretch
d2dcc51430 Changelog cleanup 2021-09-20 12:55:04 -04:00
jeremystretch
214c1d5a50 Release v3.0.3 2021-09-20 12:44:44 -04:00
jeremystretch
38be0b4976 Changelog for #7228 2021-09-20 10:23:29 -04:00
Jeremy Stretch
b86847c57e Merge pull request #7307 from royreznik/fix-7228
Fixes #7228: Round °F temperature to one decimal place
2021-09-20 10:21:43 -04:00
jeremystretch
8ba5d03280 Fixes #7301: Fix exception when deleting a large number of child prefixes 2021-09-20 10:02:03 -04:00
jeremystretch
879ffd648b Fix 'select all' widget display 2021-09-20 09:02:28 -04:00
royreznik
030c573037 Fixes #7228: Round °F temperature to one decimal place 2021-09-20 02:44:40 -07:00
jeremystretch
713e79c1a9 Fixes #7298: Restore missing object names from applied object list filters 2021-09-17 16:55:32 -04:00
Jeremy Stretch
383cdb5340 Merge pull request #7297 from netbox-community/7295-tables-cleanup
Closes #7295: General cleanup of table classes
2021-09-17 15:56:26 -04:00
jeremystretch
7b3f6f1c67 Clean up table classes 2021-09-17 15:37:19 -04:00
jeremystretch
9cb29f48a0 Use exclude() when instantiating tables to omit columns 2021-09-17 14:25:02 -04:00
jeremystretch
5e29679968 Merge 'detail' tables into primaries for each model 2021-09-17 13:55:32 -04:00
jeremystretch
84f3ab90df Changelog for #7273 2021-09-17 12:11:08 -04:00
Jeremy Stretch
34db2eb611 Merge pull request #7285 from netbox-community/7273-apiselect-sorting
Fixes #7273: Disable automatic sorting of select options fetched via API
2021-09-17 12:09:56 -04:00
jeremystretch
16d8981a3f Closes #7284: Include comments field in table/export for all appropriate models 2021-09-17 12:04:22 -04:00
jeremystretch
e67c965180 Closes #7087: Add local_context_data filter for virtual machines list 2021-09-17 10:30:31 -04:00
jeremystretch
b0abfee35b Fixes #7282: Fix KeyError exception when INSECURE_SKIP_TLS_VERIFY is true 2021-09-17 10:16:06 -04:00
jeremystretch
1e8ee5e59b Correct changelog for #7210; revert static asset change 2021-09-16 16:14:20 -04:00
jeremystretch
cc0830bf28 Closes #7087: Add search/filter forms for all organizational models 2021-09-16 16:04:46 -04:00
jeremystretch
e3e005e327 Fixes #7266: Tweak font color for form field placeholder text 2021-09-16 14:41:01 -04:00
jeremystretch
42afd80e82 Changelog for #5775 2021-09-16 14:28:17 -04:00
jeremystretch
0b2862be54 Changelog for #5775 2021-09-16 14:03:28 -04:00
Jeremy Stretch
8d703ffb36 Merge pull request #6730 from MaxRink/remote_groups
Remote groups via HTTP Headers
2021-09-16 14:02:22 -04:00
jeremystretch
6f24a938d9 Fixes #7273: Disable automatic sorting of select options fetched via API 2021-09-16 11:08:05 -04:00
jeremystretch
574b57eadb Treat compiled JS/CSS as binary when diffing 2021-09-16 11:03:55 -04:00
jeremystretch
aa05097fca Fixes #7279: Fix exception when tracing cable with no associated path 2021-09-16 08:56:26 -04:00
jeremystretch
de58f53f9f Closes #7208: Add navigation breadcrumbs for custom scripts & reports 2021-09-15 15:14:24 -04:00
jeremystretch
e738ff2fa7 Closes #6988: Order tenants alphabetically without regard to group assignment 2021-09-15 14:56:20 -04:00
jeremystretch
25f501fb12 Remove obsolete responsive_table.html template 2021-09-15 12:41:21 -04:00
jeremystretch
baf045aed6 Clean up documentation build warnings 2021-09-15 11:46:58 -04:00
jeremystretch
e813dda275 Remove default_app_config (deprecated in Django 3.2) 2021-09-15 11:18:47 -04:00
Jeremy Stretch
ca131f12db Merge pull request #7272 from cimnine/Fix7271
Remove secrets from the feature list
2021-09-15 07:56:28 -04:00
cimnine
ca72b07947 Remove secrets from the feature list 2021-09-15 11:00:51 +02:00
jeremystretch
13d8957cf1 Changelog for #7032 2021-09-14 17:05:07 -04:00
Jeremy Stretch
ca11b74c8e Merge pull request #7052 from tb-killa/#7032
Close #7032
2021-09-14 16:59:16 -04:00
jeremystretch
2ba6a6fc45 Closes #6387: Add xDSL interface type 2021-09-14 16:56:47 -04:00
jeremystretch
a6e79a1d61 Add virtualenv instructions to plugin development docs 2021-09-13 10:58:03 -04:00
jeremystretch
1f4263aa6d Fixes #7253: Remove obsolete reference to queryset cache invalidation 2021-09-13 09:36:01 -04:00
jeremystretch
147a4cbfb0 Closes #7239: Redirect global search to filtered object list when an object type is selected 2021-09-13 09:33:08 -04:00
jeremystretch
ab0a2abc54 Fixes #7248: Fix global search results section links 2021-09-13 09:21:57 -04:00
Jeremy Stretch
57abbf1058 Merge pull request #7245 from dvaccarosenna/issue/7195
Fixes #7195 update base template in plugin dev doc
2021-09-10 11:34:49 -04:00
Daniel Vaccaro-Senna
2a95e1bf71 Fixes #7195 update base template in plugin dev doc 2021-09-10 16:03:46 +01:00
jeremystretch
bd957612c6 Fixes #7167: Ensure consistent font size when using monospace formatting 2021-09-09 16:05:46 -04:00
jeremystretch
908e6a7a38 Update NetBox installation video 2021-09-09 14:46:31 -04:00
jeremystretch
4493c31216 Fixes #7227: Correct placeholder value for webhook CA file path 2021-09-09 09:10:02 -04:00
jeremystretch
7a813349f3 Correct example GraphQL queries 2021-09-09 09:07:28 -04:00
jeremystretch
ad7b8a9ac8 Fixes #7226: Exempt GraphQL API requests from CSRF inspection 2021-09-09 09:06:45 -04:00
jeremystretch
a226f06b1b PRVB 2021-09-08 16:47:04 -04:00
Max Rink
d5e5cdda23 Add Remote Group Support to the RemoteUserAuth Backend and Middleware
fix incorrect assumption about when to run the group sync

Add documentation for new Settings

format to autopep8 compliance

add first set of basic testcases

format test to comply with pep8

rename SEPERATOR to SEPARATOR

remove accidentally carried over parameter
2021-09-01 16:34:14 +02:00
Oliver
b44ec35ade Close #7032
Add URM connector to the list of available types for front and rear ports.

There are URM-P2, URM-P4 and URM-P8 connectors available.
2021-08-27 20:27:26 +02:00
245 changed files with 13499 additions and 11946 deletions

8
.gitattributes vendored
View File

@@ -1,5 +1,5 @@
*.sh text eol=lf
# Treat minified or packed JS/CSS files as binary, as they're not meant to be human-readable
*.min.* binary
*.map binary
*.pack.js binary
# Treat compiled JS/CSS files as binary, as they're not meant to be human-readable
netbox/project-static/dist/*.css binary
netbox/project-static/dist/*.js binary
netbox/project-static/dist/*.js.map binary

View File

@@ -17,7 +17,7 @@ body:
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.2
placeholder: v3.0.9
validations:
required: true
- type: dropdown
@@ -25,9 +25,9 @@ body:
label: Python version
description: What version of Python are you currently running?
options:
- 3.7
- 3.8
- 3.9
- "3.7"
- "3.8"
- "3.9"
validations:
required: true
- type: textarea

View File

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

View File

@@ -94,10 +94,6 @@ Pillow
# https://github.com/psycopg/psycopg2
psycopg2-binary
# Extensive cryptographic library (fork of pycrypto)
# https://github.com/Legrandin/pycryptodome
pycryptodome
# YAML rendering library
# https://github.com/yaml/pyyaml
PyYAML

View File

@@ -1,85 +1,4 @@
# Webhooks
A webhook is a mechanism for conveying to some external system a change that took place in NetBox. For example, you may want to notify a monitoring system whenever the status of a device is updated in NetBox. This can be done by creating a webhook for the device model in NetBox and identifying the webhook receiver. When NetBox detects a change to a device, an HTTP request containing the details of the change and who made it be sent to the specified receiver. Webhooks are managed under Logging > Webhooks.
!!! warning
Webhooks support the inclusion of user-submitted code to generate custom headers and payloads, which may pose security risks under certain conditions. Only grant permission to create or modify webhooks to trusted users.
## Configuration
* **Name** - A unique name for the webhook. The name is not included with outbound messages.
* **Object type(s)** - The type or types of NetBox object that will trigger the webhook.
* **Enabled** - If unchecked, the webhook will be inactive.
* **Events** - A webhook may trigger on any combination of create, update, and delete events. At least one event type must be selected.
* **HTTP method** - The type of HTTP request to send. Options include `GET`, `POST`, `PUT`, `PATCH`, and `DELETE`.
* **URL** - The fuly-qualified URL of the request to be sent. This may specify a destination port number if needed.
* **HTTP content type** - The value of the request's `Content-Type` header. (Defaults to `application/json`)
* **Additional headers** - Any additional headers to include with the request (optional). Add one header per line in the format `Name: Value`. Jinja2 templating is supported for this field (see below).
* **Body template** - The content of the request being sent (optional). Jinja2 templating is supported for this field (see below). If blank, NetBox will populate the request body with a raw dump of the webhook context. (If the HTTP cotent type is set to `application/json`, this will be formatted as a JSON object.)
* **Secret** - A secret string used to prove authenticity of the request (optional). This will append a `X-Hook-Signature` header to the request, consisting of a HMAC (SHA-512) hex digest of the request body using the secret as the key.
* **SSL verification** - Uncheck this option to disable validation of the receiver's SSL certificate. (Disable with caution!)
* **CA file path** - The file path to a particular certificate authority (CA) file to use when validating the receiver's SSL certificate (optional).
## Jinja2 Template Support
[Jinja2 templating](https://jinja.palletsprojects.com/) is supported for the `additional_headers` and `body_template` fields. This enables the user to convey object data in the request headers as well as to craft a customized request body. Request content can be crafted to enable the direct interaction with external systems by ensuring the outgoing message is in a format the receiver expects and understands.
For example, you might create a NetBox webhook to [trigger a Slack message](https://api.slack.com/messaging/webhooks) any time an IP address is created. You can accomplish this using the following configuration:
* Object type: IPAM > IP address
* HTTP method: `POST`
* URL: Slack incoming webhook URL
* HTTP content type: `application/json`
* Body template: `{"text": "IP address {{ data['address'] }} was created by {{ username }}!"}`
### Available Context
The following data is available as context for Jinja2 templates:
* `event` - The type of event which triggered the webhook: created, updated, or deleted.
* `model` - The NetBox model which triggered the change.
* `timestamp` - The time at which the event occurred (in [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) format).
* `username` - The name of the user account associated with the change.
* `request_id` - The unique request ID. This may be used to correlate multiple changes associated with a single request.
* `data` - A detailed representation of the object in its current state. This is typically equivalent to the model's representation in NetBox's REST API.
* `snapshots` - Minimal "snapshots" of the object state both before and after the change was made; provided ass a dictionary with keys named `prechange` and `postchange`. These are not as extensive as the fully serialized representation, but contain enough information to convey what has changed.
### Default Request Body
If no body template is specified, the request body will be populated with a JSON object containing the context data. For example, a newly created site might appear as follows:
```no-highlight
{
"event": "created",
"timestamp": "2021-03-09 17:55:33.968016+00:00",
"model": "site",
"username": "jstretch",
"request_id": "fdbca812-3142-4783-b364-2e2bd5c16c6a",
"data": {
"id": 19,
"name": "Site 1",
"slug": "site-1",
"status":
"value": "active",
"label": "Active",
"id": 1
},
"region": null,
...
},
"snapshots": {
"prechange": null,
"postchange": {
"created": "2021-03-09",
"last_updated": "2021-03-09T17:55:33.851Z",
"name": "Site 1",
"slug": "site-1",
"status": "active",
...
}
}
}
```
{!models/extras/webhook.md!}
## Webhook Processing

View File

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

View File

@@ -2,7 +2,7 @@
NetBox v2.9 introduced a new object-based permissions framework, which replace's Django's built-in permissions model. Object-based permissions enable an administrator to grant users or groups the ability to perform an action on arbitrary subsets of objects in NetBox, rather than all objects of a certain type. For example, it is possible to grant a user permission to view only sites within a particular region, or to modify only VLANs with a numeric ID within a certain range.
{!docs/models/users/objectpermission.md!}
{!models/users/objectpermission.md!}
### Example Constraint Definitions

View File

@@ -71,14 +71,3 @@ To extract the saved archive into a new installation, run the following from the
```no-highlight
tar -xf netbox_media.tar.gz
```
---
## Cache Invalidation
If you are migrating your instance of NetBox to a different machine, be sure to first invalidate the cache on the original instance by issuing the `invalidate all` management command (within the Python virtual environment):
```no-highlight
# source /opt/netbox/venv/bin/activate
(venv) # python3 manage.py invalidate all
```

View File

@@ -490,6 +490,14 @@ NetBox can be configured to support remote user authentication by inferring user
---
## REMOTE_AUTH_GROUP_SYNC_ENABLED
Default: `False`
NetBox can be configured to sync remote user groups by inferring user authentication from an HTTP header set by the HTTP reverse proxy (e.g. nginx or Apache). Set this to `True` to enable this functionality. (Local authentication will still take effect as a fallback.) (Requires `REMOTE_AUTH_ENABLED`.)
---
## REMOTE_AUTH_HEADER
Default: `'HTTP_REMOTE_USER'`
@@ -498,6 +506,54 @@ When remote user authentication is in use, this is the name of the HTTP header w
---
## REMOTE_AUTH_GROUP_HEADER
Default: `'HTTP_REMOTE_USER_GROUP'`
When remote user authentication is in use, this is the name of the HTTP header which informs NetBox of the currently authenticated user. For example, to use the request header `X-Remote-User-Groups` it needs to be set to `HTTP_X_REMOTE_USER_GROUPS`. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
---
## REMOTE_AUTH_SUPERUSER_GROUPS
Default: `[]` (Empty list)
The list of groups that promote an remote User to Superuser on Login. If group isn't present on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
---
## REMOTE_AUTH_SUPERUSERS
Default: `[]` (Empty list)
The list of users that get promoted to Superuser on Login. If user isn't present in list on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
---
## REMOTE_AUTH_STAFF_GROUPS
Default: `[]` (Empty list)
The list of groups that promote an remote User to Staff on Login. If group isn't present on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
---
## REMOTE_AUTH_STAFF_USERS
Default: `[]` (Empty list)
The list of users that get promoted to Staff on Login. If user isn't present in list on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
---
## REMOTE_AUTH_GROUP_SEPARATOR
Default: `|` (Pipe)
The Seperator upon which `REMOTE_AUTH_GROUP_HEADER` gets split into individual Groups. This needs to be coordinated with your authentication Proxy. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
---
## RELEASE_CHECK_URL
Default: None (disabled)

View File

@@ -1,10 +1,10 @@
# Circuits
{!docs/models/circuits/provider.md!}
{!docs/models/circuits/providernetwork.md!}
{!models/circuits/provider.md!}
{!models/circuits/providernetwork.md!}
---
{!docs/models/circuits/circuit.md!}
{!docs/models/circuits/circuittype.md!}
{!docs/models/circuits/circuittermination.md!}
{!models/circuits/circuit.md!}
{!models/circuits/circuittype.md!}
{!models/circuits/circuittermination.md!}

View File

@@ -1,7 +1,7 @@
# Device Types
{!docs/models/dcim/devicetype.md!}
{!docs/models/dcim/manufacturer.md!}
{!models/dcim/devicetype.md!}
{!models/dcim/manufacturer.md!}
---
@@ -30,11 +30,11 @@ Once component templates have been created, every new device that you create as
!!! note
Assignment of components from templates occurs only at the time of device creation. If you modify the templates of a device type, it will not affect devices which have already been created. However, you always have the option of adding, modifying, or deleting components on existing devices.
{!docs/models/dcim/consoleporttemplate.md!}
{!docs/models/dcim/consoleserverporttemplate.md!}
{!docs/models/dcim/powerporttemplate.md!}
{!docs/models/dcim/poweroutlettemplate.md!}
{!docs/models/dcim/interfacetemplate.md!}
{!docs/models/dcim/frontporttemplate.md!}
{!docs/models/dcim/rearporttemplate.md!}
{!docs/models/dcim/devicebaytemplate.md!}
{!models/dcim/consoleporttemplate.md!}
{!models/dcim/consoleserverporttemplate.md!}
{!models/dcim/powerporttemplate.md!}
{!models/dcim/poweroutlettemplate.md!}
{!models/dcim/interfacetemplate.md!}
{!models/dcim/frontporttemplate.md!}
{!models/dcim/rearporttemplate.md!}
{!models/dcim/devicebaytemplate.md!}

View File

@@ -1,8 +1,8 @@
# Devices and Cabling
{!docs/models/dcim/device.md!}
{!docs/models/dcim/devicerole.md!}
{!docs/models/dcim/platform.md!}
{!models/dcim/device.md!}
{!models/dcim/devicerole.md!}
{!models/dcim/platform.md!}
---
@@ -10,20 +10,20 @@
Device components represent discrete objects within a device which are used to terminate cables, house child devices, or track resources.
{!docs/models/dcim/consoleport.md!}
{!docs/models/dcim/consoleserverport.md!}
{!docs/models/dcim/powerport.md!}
{!docs/models/dcim/poweroutlet.md!}
{!docs/models/dcim/interface.md!}
{!docs/models/dcim/frontport.md!}
{!docs/models/dcim/rearport.md!}
{!docs/models/dcim/devicebay.md!}
{!docs/models/dcim/inventoryitem.md!}
{!models/dcim/consoleport.md!}
{!models/dcim/consoleserverport.md!}
{!models/dcim/powerport.md!}
{!models/dcim/poweroutlet.md!}
{!models/dcim/interface.md!}
{!models/dcim/frontport.md!}
{!models/dcim/rearport.md!}
{!models/dcim/devicebay.md!}
{!models/dcim/inventoryitem.md!}
---
{!docs/models/dcim/virtualchassis.md!}
{!models/dcim/virtualchassis.md!}
---
{!docs/models/dcim/cable.md!}
{!models/dcim/cable.md!}

View File

@@ -1,19 +1,19 @@
# IP Address Management
{!docs/models/ipam/aggregate.md!}
{!docs/models/ipam/rir.md!}
{!models/ipam/aggregate.md!}
{!models/ipam/rir.md!}
---
{!docs/models/ipam/prefix.md!}
{!docs/models/ipam/role.md!}
{!models/ipam/prefix.md!}
{!models/ipam/role.md!}
---
{!docs/models/ipam/iprange.md!}
{!docs/models/ipam/ipaddress.md!}
{!models/ipam/iprange.md!}
{!models/ipam/ipaddress.md!}
---
{!docs/models/ipam/vrf.md!}
{!docs/models/ipam/routetarget.md!}
{!models/ipam/vrf.md!}
{!models/ipam/routetarget.md!}

View File

@@ -1,8 +1,8 @@
# Power Tracking
{!docs/models/dcim/powerpanel.md!}
{!docs/models/dcim/powerfeed.md!}
{!models/dcim/powerpanel.md!}
{!models/dcim/powerfeed.md!}
# Example Power Topology
![Power distribution model](../../media/power_distribution.png)
![Power distribution model](../media/power_distribution.png)

View File

@@ -1,3 +1,3 @@
# Service Mapping
{!docs/models/ipam/service.md!}
{!models/ipam/service.md!}

View File

@@ -1,12 +1,12 @@
# Sites and Racks
{!docs/models/dcim/region.md!}
{!docs/models/dcim/sitegroup.md!}
{!docs/models/dcim/site.md!}
{!docs/models/dcim/location.md!}
{!models/dcim/region.md!}
{!models/dcim/sitegroup.md!}
{!models/dcim/site.md!}
{!models/dcim/location.md!}
---
{!docs/models/dcim/rack.md!}
{!docs/models/dcim/rackrole.md!}
{!docs/models/dcim/rackreservation.md!}
{!models/dcim/rack.md!}
{!models/dcim/rackrole.md!}
{!models/dcim/rackreservation.md!}

View File

@@ -1,4 +1,4 @@
# Tenancy Assignment
{!docs/models/tenancy/tenant.md!}
{!docs/models/tenancy/tenantgroup.md!}
{!models/tenancy/tenant.md!}
{!models/tenancy/tenantgroup.md!}

View File

@@ -1,10 +1,10 @@
# Virtualization
{!docs/models/virtualization/cluster.md!}
{!docs/models/virtualization/clustertype.md!}
{!docs/models/virtualization/clustergroup.md!}
{!models/virtualization/cluster.md!}
{!models/virtualization/clustertype.md!}
{!models/virtualization/clustergroup.md!}
---
{!docs/models/virtualization/virtualmachine.md!}
{!docs/models/virtualization/vminterface.md!}
{!models/virtualization/virtualmachine.md!}
{!models/virtualization/vminterface.md!}

View File

@@ -1,4 +1,4 @@
# VLAN Management
{!docs/models/ipam/vlan.md!}
{!docs/models/ipam/vlangroup.md!}
{!models/ipam/vlan.md!}
{!models/ipam/vlangroup.md!}

View File

@@ -1,44 +1,4 @@
# Custom Fields
Each model in NetBox is represented in the database as a discrete table, and each attribute of a model exists as a column within its table. For example, sites are stored in the `dcim_site` table, which has columns named `name`, `facility`, `physical_address`, and so on. As new attributes are added to objects throughout the development of NetBox, tables are expanded to include new rows.
However, some users might want to store additional object attributes that are somewhat esoteric in nature, and that would not make sense to include in the core NetBox database schema. For instance, suppose your organization needs to associate each device with a ticket number correlating it with an internal support system record. This is certainly a legitimate use for NetBox, but it's not a common enough need to warrant including a field for _every_ NetBox installation. Instead, you can create a custom field to hold this data.
Within the database, custom fields are stored as JSON data directly alongside each object. This alleviates the need for complex queries when retrieving objects.
## Creating Custom Fields
Custom fields may be created by navigating to Customization > Custom Fields. NetBox supports six types of custom field:
* Text: Free-form text (up to 255 characters)
* Integer: A whole number (positive or negative)
* Boolean: True or false
* Date: A date in ISO 8601 format (YYYY-MM-DD)
* URL: This will be presented as a link in the web UI
* 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.
Marking a field as required will force the user to provide a value for the field when creating a new object or when saving an existing object. A default value for the field may also be provided. Use "true" or "false" for boolean fields, or the exact value of a choice for selection fields.
The filter logic controls how values are matched when filtering objects by the custom field. Loose filtering (the default) matches on a partial value, whereas exact matching requires a complete match of the given string to a field's value. For example, exact filtering with the string "red" will only match the exact value "red", whereas loose filtering will match on the values "red", "red-orange", or "bored". Setting the filter logic to "disabled" disables filtering by the field entirely.
A custom field must be assigned to one or more object types, or models, in NetBox. Once created, custom fields will automatically appear as part of these models in the web UI and REST API. Note that not all models support custom fields.
### Custom Field Validation
NetBox supports limited custom validation for custom field values. Following are the types of validation enforced for each field type:
* Text: Regular expression (optional)
* Integer: Minimum and/or maximum value (optional)
* Selection: Must exactly match one of the prescribed choices
### Custom Selection Fields
Each custom selection field must have at least two choices. These are specified as a comma-separated list. Choices appear in forms in the order they are listed. Note that choice values are saved exactly as they appear, so it's best to avoid superfluous punctuation or symbols where possible.
If a default value is specified for a selection field, it must exactly match one of the provided choices. The value of a multiple selection field will always return a list, even if only one value is selected.
{!models/extras/customfield.md!}
## Custom Fields in Templates

View File

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

View File

@@ -1,40 +1,4 @@
# Export Templates
NetBox allows users to define custom templates that can be used when exporting objects. To create an export template, navigate to Customization > Export Templates.
Each export template is associated with a certain type of object. For instance, if you create an export template for VLANs, your custom template will appear under the "Export" button on the VLANs list. Each export template must have a name, and may optionally designate a specific export [MIME type](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types) and/or file extension.
Export templates must be written in [Jinja2](https://jinja.palletsprojects.com/).
!!! note
The name `table` is reserved for internal use.
!!! warning
Export templates are rendered using user-submitted code, which may pose security risks under certain conditions. Only grant permission to create or modify export templates to trusted users.
The list of objects returned from the database when rendering an export template is stored in the `queryset` variable, which you'll typically want to iterate through using a `for` loop. Object properties can be access by name. For example:
```jinja2
{% for rack in queryset %}
Rack: {{ rack.name }}
Site: {{ rack.site.name }}
Height: {{ rack.u_height }}U
{% endfor %}
```
To access custom fields of an object within a template, use the `cf` attribute. For example, `{{ obj.cf.color }}` will return the value (if any) for a custom field named `color` on `obj`.
If you need to use the config context data in an export template, you'll should use the function `get_config_context` to get all the config context data. For example:
```
{% for server in queryset %}
{% set data = server.get_config_context() %}
{{ data.syslog }}
{% endfor %}
```
The `as_attachment` attribute of an export template controls its behavior when rendered. If true, the rendered content will be returned to the user as a downloadable file. If false, it will be displayed within the browser. (This may be handy e.g. for generating HTML content.)
A MIME type and file extension can optionally be defined for each export template. The default MIME type is `text/plain`.
{!models/extras/exporttemplate.md!}
## REST API Integration

View File

@@ -97,6 +97,20 @@ The recording of one or more failure messages will automatically flag a report a
To perform additional tasks, such as sending an email or calling a webhook, after a report has been run, extend the `post_run()` method. The status of the report is available as `self.failed` and the results object is `self.result`.
By default, reports within a module are ordered alphabetically in the reports list page. To return reports in a specific order, you can define the `report_order` variable at the end of your module. The `report_order` variable is a tuple which contains each Report class in the desired order. Any reports that are omitted from this list will be listed last.
```
from extras.reports import Report
class DeviceConnectionsReport(Report)
pass
class DeviceIPsReport(Report)
pass
report_order = (DeviceIPsReport, DeviceConnectionsReport)
```
Once you have created a report, it will appear in the reports list. Initially, reports will have no results associated with them. To generate results, run the report.
## Running Reports
@@ -104,7 +118,7 @@ Once you have created a report, it will appear in the reports list. Initially, r
!!! note
To run a report, a user must be assigned the `extras.run_report` permission. This is achieved by assigning the user (or group) a permission on the Report object and specifying the `run` action in the admin UI as shown below.
![Adding the run action to a permission](../../media/admin_ui_run_permission.png)
![Adding the run action to a permission](/media/admin_ui_run_permission.png)
### Via the Web UI

View File

@@ -11,7 +11,7 @@ curl -H "Authorization: Token $TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
http://netbox/graphql/ \
--data '{"query": "query {circuits(status:\"active\" {cid provider {name}}}"}'
--data '{"query": "query {circuit_list(status:\"active\") {cid provider {name}}}"}'
```
The response will include the requested data formatted as JSON:
@@ -54,7 +54,7 @@ For more detail on constructing GraphQL queries, see the [Graphene documentation
The GraphQL API employs the same filtering logic as the UI and REST API. Filters can be specified as key-value pairs within parentheses immediately following the query name. For example, the following will return only sites within the North Carolina region with a status of active:
```
{"query": "query {sites(region:\"north-carolina\", status:\"active\") {name}}"}
{"query": "query {site_list(region:\"north-carolina\", status:\"active\") {name}}"}
```
## Authentication

View File

@@ -10,7 +10,6 @@ NetBox is an infrastructure resource modeling (IRM) application designed to empo
* **Connections** - Network, console, and power connections among devices
* **Virtualization** - Virtual machines and clusters
* **Data circuits** - Long-haul communications circuits and providers
* **Secrets** - Encrypted storage of sensitive credentials
## What NetBox Is Not

View File

@@ -259,10 +259,10 @@ python3 manage.py createsuperuser
NetBox includes a `housekeeping` management command that handles some recurring cleanup tasks, such as clearing out old sessions and expired change records. Although this command may be run manually, it is recommended to configure a scheduled job using the system's `cron` daemon or a similar utility.
A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be copied to your system's daily cron task directory, or included within the crontab directly. (If installing NetBox into a nonstandard path, be sure to update the system paths within this script first.)
A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be copied to or linked from your system's daily cron task directory, or included within the crontab directly. (If installing NetBox into a nonstandard path, be sure to update the system paths within this script first.)
```shell
cp /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/
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

@@ -11,9 +11,9 @@ The following sections detail how to set up a new instance of NetBox:
5. [HTTP server](5-http-server.md)
6. [LDAP authentication](6-ldap.md) (optional)
The video below demonstrates the installation of NetBox v2.10.3 on Ubuntu 20.04 for your reference.
The video below demonstrates the installation of NetBox v3.0 on Ubuntu 20.04 for your reference.
<iframe width="560" height="315" src="https://www.youtube.com/embed/dFANGlxXEng" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
<iframe width="560" height="315" src="https://www.youtube.com/embed/7Fpd2-q9_28" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
## Requirements

View File

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

@@ -25,7 +25,7 @@ A cable may be traced from either of its endpoints by clicking the "trace" butto
In the example below, three individual cables comprise a path between devices A and D:
![Cable path](../../media/models/dcim_cable_trace.png)
![Cable path](../media/models/dcim_cable_trace.png)
Traced from Interface 1 on Device A, NetBox will show the following path:

View File

@@ -0,0 +1,41 @@
# Custom Fields
Each model in NetBox is represented in the database as a discrete table, and each attribute of a model exists as a column within its table. For example, sites are stored in the `dcim_site` table, which has columns named `name`, `facility`, `physical_address`, and so on. As new attributes are added to objects throughout the development of NetBox, tables are expanded to include new rows.
However, some users might want to store additional object attributes that are somewhat esoteric in nature, and that would not make sense to include in the core NetBox database schema. For instance, suppose your organization needs to associate each device with a ticket number correlating it with an internal support system record. This is certainly a legitimate use for NetBox, but it's not a common enough need to warrant including a field for _every_ NetBox installation. Instead, you can create a custom field to hold this data.
Within the database, custom fields are stored as JSON data directly alongside each object. This alleviates the need for complex queries when retrieving objects.
## Creating Custom Fields
Custom fields may be created by navigating to Customization > Custom Fields. NetBox supports six types of custom field:
* Text: Free-form text (up to 255 characters)
* Integer: A whole number (positive or negative)
* Boolean: True or false
* Date: A date in ISO 8601 format (YYYY-MM-DD)
* URL: This will be presented as a link in the web UI
* 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.
Marking a field as required will force the user to provide a value for the field when creating a new object or when saving an existing object. A default value for the field may also be provided. Use "true" or "false" for boolean fields, or the exact value of a choice for selection fields.
The filter logic controls how values are matched when filtering objects by the custom field. Loose filtering (the default) matches on a partial value, whereas exact matching requires a complete match of the given string to a field's value. For example, exact filtering with the string "red" will only match the exact value "red", whereas loose filtering will match on the values "red", "red-orange", or "bored". Setting the filter logic to "disabled" disables filtering by the field entirely.
A custom field must be assigned to one or more object types, or models, in NetBox. Once created, custom fields will automatically appear as part of these models in the web UI and REST API. Note that not all models support custom fields.
### Custom Field Validation
NetBox supports limited custom validation for custom field values. Following are the types of validation enforced for each field type:
* Text: Regular expression (optional)
* Integer: Minimum and/or maximum value (optional)
* Selection: Must exactly match one of the prescribed choices
### Custom Selection Fields
Each custom selection field must have at least two choices. These are specified as a comma-separated list. Choices appear in forms in the order they are listed. Note that choice values are saved exactly as they appear, so it's best to avoid superfluous punctuation or symbols where possible.
If a default value is specified for a selection field, it must exactly match one of the provided choices. The value of a multiple selection field will always return a list, even if only one value is selected.

View File

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

View File

@@ -0,0 +1,37 @@
# Export Templates
NetBox allows users to define custom templates that can be used when exporting objects. To create an export template, navigate to Customization > Export Templates.
Each export template is associated with a certain type of object. For instance, if you create an export template for VLANs, your custom template will appear under the "Export" button on the VLANs list. Each export template must have a name, and may optionally designate a specific export [MIME type](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types) and/or file extension.
Export templates must be written in [Jinja2](https://jinja.palletsprojects.com/).
!!! note
The name `table` is reserved for internal use.
!!! warning
Export templates are rendered using user-submitted code, which may pose security risks under certain conditions. Only grant permission to create or modify export templates to trusted users.
The list of objects returned from the database when rendering an export template is stored in the `queryset` variable, which you'll typically want to iterate through using a `for` loop. Object properties can be access by name. For example:
```jinja2
{% for rack in queryset %}
Rack: {{ rack.name }}
Site: {{ rack.site.name }}
Height: {{ rack.u_height }}U
{% endfor %}
```
To access custom fields of an object within a template, use the `cf` attribute. For example, `{{ obj.cf.color }}` will return the value (if any) for a custom field named `color` on `obj`.
If you need to use the config context data in an export template, you'll should use the function `get_config_context` to get all the config context data. For example:
```
{% for server in queryset %}
{% set data = server.get_config_context() %}
{{ data.syslog }}
{% endfor %}
```
The `as_attachment` attribute of an export template controls its behavior when rendered. If true, the rendered content will be returned to the user as a downloadable file. If false, it will be displayed within the browser. (This may be handy e.g. for generating HTML content.)
A MIME type and file extension can optionally be defined for each export template. The default MIME type is `text/plain`.

View File

@@ -0,0 +1,82 @@
# Webhooks
A webhook is a mechanism for conveying to some external system a change that took place in NetBox. For example, you may want to notify a monitoring system whenever the status of a device is updated in NetBox. This can be done by creating a webhook for the device model in NetBox and identifying the webhook receiver. When NetBox detects a change to a device, an HTTP request containing the details of the change and who made it be sent to the specified receiver. Webhooks are managed under Logging > Webhooks.
!!! warning
Webhooks support the inclusion of user-submitted code to generate custom headers and payloads, which may pose security risks under certain conditions. Only grant permission to create or modify webhooks to trusted users.
## Configuration
* **Name** - A unique name for the webhook. The name is not included with outbound messages.
* **Object type(s)** - The type or types of NetBox object that will trigger the webhook.
* **Enabled** - If unchecked, the webhook will be inactive.
* **Events** - A webhook may trigger on any combination of create, update, and delete events. At least one event type must be selected.
* **HTTP method** - The type of HTTP request to send. Options include `GET`, `POST`, `PUT`, `PATCH`, and `DELETE`.
* **URL** - The fuly-qualified URL of the request to be sent. This may specify a destination port number if needed.
* **HTTP content type** - The value of the request's `Content-Type` header. (Defaults to `application/json`)
* **Additional headers** - Any additional headers to include with the request (optional). Add one header per line in the format `Name: Value`. Jinja2 templating is supported for this field (see below).
* **Body template** - The content of the request being sent (optional). Jinja2 templating is supported for this field (see below). If blank, NetBox will populate the request body with a raw dump of the webhook context. (If the HTTP cotent type is set to `application/json`, this will be formatted as a JSON object.)
* **Secret** - A secret string used to prove authenticity of the request (optional). This will append a `X-Hook-Signature` header to the request, consisting of a HMAC (SHA-512) hex digest of the request body using the secret as the key.
* **SSL verification** - Uncheck this option to disable validation of the receiver's SSL certificate. (Disable with caution!)
* **CA file path** - The file path to a particular certificate authority (CA) file to use when validating the receiver's SSL certificate (optional).
## Jinja2 Template Support
[Jinja2 templating](https://jinja.palletsprojects.com/) is supported for the `additional_headers` and `body_template` fields. This enables the user to convey object data in the request headers as well as to craft a customized request body. Request content can be crafted to enable the direct interaction with external systems by ensuring the outgoing message is in a format the receiver expects and understands.
For example, you might create a NetBox webhook to [trigger a Slack message](https://api.slack.com/messaging/webhooks) any time an IP address is created. You can accomplish this using the following configuration:
* Object type: IPAM > IP address
* HTTP method: `POST`
* URL: Slack incoming webhook URL
* HTTP content type: `application/json`
* Body template: `{"text": "IP address {{ data['address'] }} was created by {{ username }}!"}`
### Available Context
The following data is available as context for Jinja2 templates:
* `event` - The type of event which triggered the webhook: created, updated, or deleted.
* `model` - The NetBox model which triggered the change.
* `timestamp` - The time at which the event occurred (in [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) format).
* `username` - The name of the user account associated with the change.
* `request_id` - The unique request ID. This may be used to correlate multiple changes associated with a single request.
* `data` - A detailed representation of the object in its current state. This is typically equivalent to the model's representation in NetBox's REST API.
* `snapshots` - Minimal "snapshots" of the object state both before and after the change was made; provided ass a dictionary with keys named `prechange` and `postchange`. These are not as extensive as the fully serialized representation, but contain enough information to convey what has changed.
### Default Request Body
If no body template is specified, the request body will be populated with a JSON object containing the context data. For example, a newly created site might appear as follows:
```json
{
"event": "created",
"timestamp": "2021-03-09 17:55:33.968016+00:00",
"model": "site",
"username": "jstretch",
"request_id": "fdbca812-3142-4783-b364-2e2bd5c16c6a",
"data": {
"id": 19,
"name": "Site 1",
"slug": "site-1",
"status":
"value": "active",
"label": "Active",
"id": 1
},
"region": null,
...
},
"snapshots": {
"prechange": null,
"postchange": {
"created": "2021-03-09",
"last_updated": "2021-03-09T17:55:33.851Z",
"name": "Site 1",
"slug": "site-1",
"status": "active",
...
}
}
}
```

View File

@@ -9,3 +9,6 @@ IP also ranges share the same functional roles as prefixes and VLANs, although t
* Deprecated - No longer in use
The status of a range does _not_ have any impact on its member IP addresses, which may have their statuses modified separately.
!!! note
The maximum supported size of an IP range is 2^32 - 1.

View File

@@ -17,12 +17,12 @@ However, keep in mind that each piece of functionality is entirely optional. For
## Initial Setup
## Plugin Structure
### Plugin Structure
Although the specific structure of a plugin is largely left to the discretion of its authors, a typical NetBox plugin looks something like this:
```no-highlight
plugin_name/
project-name/
- plugin_name/
- templates/
- plugin_name/
@@ -38,13 +38,13 @@ plugin_name/
- setup.py
```
The top level is the project root. Immediately within the root should exist several items:
The top level is the project root, which can have any name that you like. Immediately within the root should exist several items:
* `setup.py` - This is a standard installation script used to install the plugin package within the Python environment.
* `README` - A brief introduction to your plugin, how to install and configure it, where to find help, and any other pertinent information. It is recommended to write README files using a markup language such as Markdown.
* The plugin source directory, with the same name as your plugin.
* The plugin source directory, with the same name as your plugin. This must be a valid Python package name (e.g. no spaces or hyphens).
The plugin source directory contains all of the actual Python code and other resources used by your plugin. Its structure is left to the author's discretion, however it is recommended to follow best practices as outlined in the [Django documentation](https://docs.djangoproject.com/en/stable/intro/reusable-apps/). At a minimum, this directory **must** contain an `__init__.py` file containing an instance of NetBox's `PluginConfig` class.
The plugin source directory contains all the actual Python code and other resources used by your plugin. Its structure is left to the author's discretion, however it is recommended to follow best practices as outlined in the [Django documentation](https://docs.djangoproject.com/en/stable/intro/reusable-apps/). At a minimum, this directory **must** contain an `__init__.py` file containing an instance of NetBox's `PluginConfig` class.
### Create setup.py
@@ -118,6 +118,21 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i
All required settings must be configured by the user. If a configuration parameter is listed in both `required_settings` and `default_settings`, the default setting will be ignored.
### Create a Virtual Environment
It is strongly recommended to create a Python [virtual environment](https://docs.python.org/3/tutorial/venv.html) specific to your plugin. This will afford you complete control over the installed versions of all dependencies and avoid conflicting with any system packages. This environment can live wherever you'd like, however it should be excluded from revision control. (A popular convention is to keep all virtual environments in the user's home directory, e.g. `~/.virtualenvs/`.)
```shell
python3 -m venv /path/to/my/venv
```
You can make NetBox available within this environment by creating a path file pointing to its location. This will add NetBox to the Python path upon activation. (Be sure to adjust the command below to specify your actual virtual environment path, Python version, and NetBox installation.)
```shell
cd $VENV/lib/python3.7/site-packages/
echo /opt/netbox/netbox > netbox.pth
```
### Install the Plugin for Development
To ease development, it is recommended to go ahead and install the plugin at this point using setuptools' `develop` mode. This will create symbolic links within your Python environment to the plugin development directory. Call `setup.py` from the plugin's root directory with the `develop` argument (instead of `install`):
@@ -218,7 +233,7 @@ NetBox provides a base template to ensure a consistent user experience, which pl
For more information on how template blocks work, consult the [Django documentation](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#block).
```jinja2
{% extends 'base.html' %}
{% extends 'base/layout.html' %}
{% block content %}
{% with config=settings.PLUGINS_CONFIG.netbox_animal_sounds %}

View File

@@ -1,5 +1,171 @@
# NetBox v3.0
## v3.0.9 (2021-11-03)
### Enhancements
* [#6529](https://github.com/netbox-community/netbox/issues/6529) - Introduce the `runscript` management command
* [#6930](https://github.com/netbox-community/netbox/issues/6930) - Add an optional "ID" column to all tables
* [#7668](https://github.com/netbox-community/netbox/issues/7668) - Add "view elevations" button to location view
### Bug Fixes
* [#7599](https://github.com/netbox-community/netbox/issues/7599) - Improve color mode preference handling
* [#7601](https://github.com/netbox-community/netbox/issues/7601) - Correct devices count for locations within global search results
* [#7612](https://github.com/netbox-community/netbox/issues/7612) - Strip HTML from custom field descriptions
* [#7628](https://github.com/netbox-community/netbox/issues/7628) - Fix `load_yaml` method for custom scripts
* [#7643](https://github.com/netbox-community/netbox/issues/7643) - Fix circuit assignment when creating multiple terminations simultaneously
* [#7644](https://github.com/netbox-community/netbox/issues/7644) - Prevent inadvertent deletion of prior change records when deleting objects (#7333 revisited)
* [#7647](https://github.com/netbox-community/netbox/issues/7647) - Require interface assignment when designating IP address as primary for device/VM during CSV import
* [#7664](https://github.com/netbox-community/netbox/issues/7664) - Preserve initial form data when bulk edit validation fails
* [#7717](https://github.com/netbox-community/netbox/issues/7717) - Restore missing tags column on IP range table
* [#7721](https://github.com/netbox-community/netbox/issues/7721) - Retain pagination preference when `MAX_PAGE_SIZE` is zero
---
## v3.0.8 (2021-10-20)
### Enhancements
* [#7551](https://github.com/netbox-community/netbox/issues/7551) - Add UI field to filter interfaces by kind
* [#7561](https://github.com/netbox-community/netbox/issues/7561) - Add a utilization column to the IP ranges table
### Bug Fixes
* [#7300](https://github.com/netbox-community/netbox/issues/7300) - Fix incorrect Device LLDP interface row coloring
* [#7495](https://github.com/netbox-community/netbox/issues/7495) - Fix navigation UI issue that caused improper element overlap
* [#7529](https://github.com/netbox-community/netbox/issues/7529) - Restore horizontal scrolling for tables in narrow viewports
* [#7534](https://github.com/netbox-community/netbox/issues/7534) - Avoid exception when utilizing "create and add another" twice in succession
* [#7544](https://github.com/netbox-community/netbox/issues/7544) - Fix multi-value filtering of custom field objects
* [#7545](https://github.com/netbox-community/netbox/issues/7545) - Fix incorrect display of update/delete events for webhooks
* [#7550](https://github.com/netbox-community/netbox/issues/7550) - Fix rendering of UTF8-encoded data in change records
* [#7556](https://github.com/netbox-community/netbox/issues/7556) - Fix display of version when new release is available
* [#7584](https://github.com/netbox-community/netbox/issues/7584) - Fix alignment of object identifier under object view
---
## v3.0.7 (2021-10-08)
### Enhancements
* [#6879](https://github.com/netbox-community/netbox/issues/6879) - Improve ability to toggle images/labels in rack elevations
* [#7485](https://github.com/netbox-community/netbox/issues/7485) - Add USB micro AB type
### Bug Fixes
* [#7051](https://github.com/netbox-community/netbox/issues/7051) - Fix permissions evaluation and improve error handling for connected device REST API endpoint
* [#7471](https://github.com/netbox-community/netbox/issues/7471) - Correct redirect URL when attaching images via "add another" button
* [#7474](https://github.com/netbox-community/netbox/issues/7474) - Fix AttributeError exception when rendering a report or custom script
* [#7479](https://github.com/netbox-community/netbox/issues/7479) - Fix parent interface choices when bulk editing VM interfaces
---
## v3.0.6 (2021-10-06)
### Enhancements
* [#6850](https://github.com/netbox-community/netbox/issues/6850) - Default to current user when creating journal entries via REST API
* [#6955](https://github.com/netbox-community/netbox/issues/6955) - Include type, ID, and slug on object view
* [#7394](https://github.com/netbox-community/netbox/issues/7394) - Enable filtering cables by termination type & ID in REST API
* [#7462](https://github.com/netbox-community/netbox/issues/7462) - Include count of assigned virtual machines under platform view
### Bug Fixes
* [#7442](https://github.com/netbox-community/netbox/issues/7442) - Fix missing actions column on user-configured tables
* [#7446](https://github.com/netbox-community/netbox/issues/7446) - Fix exception when viewing a large number of child IPs within a prefix
* [#7455](https://github.com/netbox-community/netbox/issues/7455) - Fix site/provider network validation for circuit termination API serializer
* [#7459](https://github.com/netbox-community/netbox/issues/7459) - Pre-populate location data when adding a device to a rack
* [#7460](https://github.com/netbox-community/netbox/issues/7460) - Fix filtering connections by site ID
---
## v3.0.5 (2021-10-04)
### Enhancements
* [#5925](https://github.com/netbox-community/netbox/issues/5925) - Always show IP addresses tab under prefix view
* [#6423](https://github.com/netbox-community/netbox/issues/6423) - Cache rendered REST API specifications
* [#6708](https://github.com/netbox-community/netbox/issues/6708) - Add image attachment support for circuits, power panels
* [#7387](https://github.com/netbox-community/netbox/issues/7387) - Enable arbitrary ordering of custom scripts
### Bug Fixes
* [#6433](https://github.com/netbox-community/netbox/issues/6433) - Fix bulk editing of child prefixes under aggregate view
* [#6817](https://github.com/netbox-community/netbox/issues/6817) - Custom field columns should be removed from tables upon their deletion
* [#6895](https://github.com/netbox-community/netbox/issues/6895) - Remove errant markup for null values in CSV export
* [#7215](https://github.com/netbox-community/netbox/issues/7215) - Prevent rack elevations from overlapping when higher width is specified
* [#7373](https://github.com/netbox-community/netbox/issues/7373) - Fix flashing when server, client, and browser color-mode preferences are mismatched
* [#7397](https://github.com/netbox-community/netbox/issues/7397) - Fix AttributeError exception when rendering export template for devices via REST API
* [#7401](https://github.com/netbox-community/netbox/issues/7401) - Pin `jsonschema` package to v3.2.0 to fix REST API docs rendering
* [#7411](https://github.com/netbox-community/netbox/issues/7411) - Fix exception in UI when adding member devices to virtual chassis
* [#7412](https://github.com/netbox-community/netbox/issues/7412) - Fix exception in UI when adding child device to device bay
* [#7417](https://github.com/netbox-community/netbox/issues/7417) - Prevent exception when filtering objects list by invalid tag
* [#7425](https://github.com/netbox-community/netbox/issues/7425) - Housekeeping command should honor zero verbosity
* [#7427](https://github.com/netbox-community/netbox/issues/7427) - Don't select hidden rows when selecting all in a table
---
## v3.0.4 (2021-09-29)
### Enhancements
* [#6917](https://github.com/netbox-community/netbox/issues/6917) - Make IP assigned checkmark in IP table link to interface
* [#6973](https://github.com/netbox-community/netbox/issues/6973) - Enable custom ordering of reports
* [#7022](https://github.com/netbox-community/netbox/issues/7022) - Add ITA type C (CEE 7/16) power port type
* [#7118](https://github.com/netbox-community/netbox/issues/7118) - Render URL custom fields as hyperlinks in object tables
* [#7314](https://github.com/netbox-community/netbox/issues/7314) - Add SMA 905/906 fiber port types
* [#7323](https://github.com/netbox-community/netbox/issues/7323) - Add serial filter field for racks & devices
* [#7372](https://github.com/netbox-community/netbox/issues/7372) - Link to local docs for model from object add/edit views
* [#7389](https://github.com/netbox-community/netbox/issues/7389) - Linkify tenant group in tenants list
### Bug Fixes
* [#7252](https://github.com/netbox-community/netbox/issues/7252) - Validate IP range size does not exceed max supported value
* [#7294](https://github.com/netbox-community/netbox/issues/7294) - Fix SVG rendering for cable traces ending at unoccupied front ports
* [#7304](https://github.com/netbox-community/netbox/issues/7304) - Require explicit values for all required choice fields during CSV import
* [#7321](https://github.com/netbox-community/netbox/issues/7321) - Don't overwrite multi-select custom fields during bulk edit
* [#7324](https://github.com/netbox-community/netbox/issues/7324) - Fix TypeError exception in web UI when filtering objects using single-choice filters
* [#7333](https://github.com/netbox-community/netbox/issues/7333) - Prevent inadvertent deletion of prior change records when deleting objects
* [#7341](https://github.com/netbox-community/netbox/issues/7341) - Fix incorrect URL in circuit breadcrumbs
* [#7353](https://github.com/netbox-community/netbox/issues/7353) - Fix bulk creation of device/VM components via list view
* [#7356](https://github.com/netbox-community/netbox/issues/7356) - Fix display of model documentation when adding device components
* [#7358](https://github.com/netbox-community/netbox/issues/7358) - Add missing `choices` column to custom field CSV import form
* [#7360](https://github.com/netbox-community/netbox/issues/7360) - Correct redirection URL after removing child device from device bay
* [#7365](https://github.com/netbox-community/netbox/issues/7365) - Optimize performance when calculating prefix utilization
* [#7374](https://github.com/netbox-community/netbox/issues/7374) - Add missing `face` parameter to API elevations request when editing device
* [#7392](https://github.com/netbox-community/netbox/issues/7392) - Fix "help" links for custom fields, other models
---
## v3.0.3 (2021-09-20)
### Enhancements
* [#5775](https://github.com/netbox-community/netbox/issues/5775) - Enable synchronization of groups for remote authentication backend
* [#6387](https://github.com/netbox-community/netbox/issues/6387) - Add xDSL interface type
* [#6988](https://github.com/netbox-community/netbox/issues/6988) - Order tenants alphabetically without regard to group assignment
* [#7032](https://github.com/netbox-community/netbox/issues/7032) - Add URM port types
* [#7087](https://github.com/netbox-community/netbox/issues/7087) - Add `local_context_data` filter for virtual machines list
* [#7208](https://github.com/netbox-community/netbox/issues/7208) - Add navigation breadcrumbs for custom scripts & reports
* [#7210](https://github.com/netbox-community/netbox/issues/7210) - Add search/filter forms for all organizational models
* [#7239](https://github.com/netbox-community/netbox/issues/7239) - Redirect global search to filtered object list when an object type is selected
* [#7284](https://github.com/netbox-community/netbox/issues/7284) - Include comments field in table/export for all appropriate models
### Bug Fixes
* [#7167](https://github.com/netbox-community/netbox/issues/7167) - Ensure consistent font size when using monospace formatting
* [#7226](https://github.com/netbox-community/netbox/issues/7226) - Exempt GraphQL API requests from CSRF inspection
* [#7228](https://github.com/netbox-community/netbox/issues/7228) - Improve temperature conversions under device status
* [#7248](https://github.com/netbox-community/netbox/issues/7248) - Fix global search results section links
* [#7266](https://github.com/netbox-community/netbox/issues/7266) - Tweak font color for form field placeholder text
* [#7273](https://github.com/netbox-community/netbox/issues/7273) - Fix natural ordering of device components in UI form fields
* [#7279](https://github.com/netbox-community/netbox/issues/7279) - Fix exception when tracing cable with no associated path
* [#7282](https://github.com/netbox-community/netbox/issues/7282) - Fix KeyError exception when `INSECURE_SKIP_TLS_VERIFY` is true
* [#7298](https://github.com/netbox-community/netbox/issues/7298) - Restore missing object names from applied object list filters
* [#7301](https://github.com/netbox-community/netbox/issues/7301) - Fix exception when deleting a large number of child prefixes
---
## v3.0.2 (2021-09-08)
### Bug Fixes

View File

@@ -2,7 +2,7 @@
The NetBox REST API primarily employs token-based authentication. For convenience, cookie-based authentication can also be used when navigating the browsable API.
{!docs/models/users/token.md!}
{!models/users/token.md!}
## Authenticating to the API

View File

@@ -3,9 +3,6 @@ site_dir: netbox/project-static/docs
site_url: https://netbox.readthedocs.io/
repo_name: netbox-community/netbox
repo_url: https://github.com/netbox-community/netbox
python:
install:
- requirements: docs/requirements.txt
theme:
name: material
icon:
@@ -24,13 +21,14 @@ extra:
- icon: fontawesome/brands/github
link: https://github.com/netbox-community/netbox
- icon: fontawesome/brands/slack
link: https://slack.netbox.dev
link: https://netdev.chat/
extra_css:
- extra.css
markdown_extensions:
- admonition
- attr_list
- markdown_include.include:
base_path: 'docs/'
headingOffset: 1
- pymdownx.emoji:
emoji_index: !!python/name:materialx.emoji.twemoji
@@ -67,7 +65,7 @@ nav:
- Customization:
- Custom Fields: 'customization/custom-fields.md'
- Custom Validation: 'customization/custom-validation.md'
- Custom Links: 'customization/custom-links.md'
- Custom Links: 'models/extras/customlink.md'
- Export Templates: 'customization/export-templates.md'
- Custom Scripts: 'customization/custom-scripts.md'
- Reports: 'customization/reports.md'

View File

@@ -1 +0,0 @@
default_app_config = 'circuits.apps.CircuitsConfig'

View File

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

View File

@@ -1,501 +0,0 @@
from django import forms
from django.utils.translation import gettext as _
from dcim.models import Region, Site, SiteGroup
from extras.forms import (
AddRemoveTagsForm, CustomFieldModelBulkEditForm, CustomFieldModelFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm,
)
from extras.models import Tag
from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant
from utilities.forms import (
add_blank_choice, BootstrapMixin, CommentField, CSVChoiceField, CSVModelChoiceField, DatePicker,
DynamicModelChoiceField, DynamicModelMultipleChoiceField, SelectSpeedWidget, SmallTextarea, SlugField,
StaticSelect, StaticSelectMultiple, TagFilterField,
)
from .choices import CircuitStatusChoices
from .models import *
#
# Providers
#
class ProviderForm(BootstrapMixin, CustomFieldModelForm):
slug = SlugField()
comments = CommentField()
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta:
model = Provider
fields = [
'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'tags',
]
fieldsets = (
('Provider', ('name', 'slug', 'asn', 'tags')),
('Support Info', ('account', 'portal_url', 'noc_contact', 'admin_contact')),
)
widgets = {
'noc_contact': SmallTextarea(
attrs={'rows': 5}
),
'admin_contact': SmallTextarea(
attrs={'rows': 5}
),
}
help_texts = {
'name': "Full name of the provider",
'asn': "BGP autonomous system number (if applicable)",
'portal_url': "URL of the provider's customer support portal",
'noc_contact': "NOC email address and phone number",
'admin_contact': "Administrative contact email address and phone number",
}
class ProviderCSVForm(CustomFieldModelCSVForm):
slug = SlugField()
class Meta:
model = Provider
fields = (
'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments',
)
class ProviderBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Provider.objects.all(),
widget=forms.MultipleHiddenInput
)
asn = forms.IntegerField(
required=False,
label='ASN'
)
account = forms.CharField(
max_length=30,
required=False,
label='Account number'
)
portal_url = forms.URLField(
required=False,
label='Portal'
)
noc_contact = forms.CharField(
required=False,
widget=SmallTextarea,
label='NOC contact'
)
admin_contact = forms.CharField(
required=False,
widget=SmallTextarea,
label='Admin contact'
)
comments = CommentField(
widget=SmallTextarea,
label='Comments'
)
class Meta:
nullable_fields = [
'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments',
]
class ProviderFilterForm(BootstrapMixin, 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,
label=_('Region'),
fetch_trigger='open'
)
site_group_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
label=_('Site group'),
fetch_trigger='open'
)
site_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
required=False,
query_params={
'region_id': '$region_id',
'site_group_id': '$site_group_id',
},
label=_('Site'),
fetch_trigger='open'
)
asn = forms.IntegerField(
required=False,
label=_('ASN')
)
tag = TagFilterField(model)
#
# Provider networks
#
class ProviderNetworkForm(BootstrapMixin, CustomFieldModelForm):
provider = DynamicModelChoiceField(
queryset=Provider.objects.all()
)
comments = CommentField()
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta:
model = ProviderNetwork
fields = [
'provider', 'name', 'description', 'comments', 'tags',
]
fieldsets = (
('Provider Network', ('provider', 'name', 'description', 'tags')),
)
class ProviderNetworkCSVForm(CustomFieldModelCSVForm):
provider = CSVModelChoiceField(
queryset=Provider.objects.all(),
to_field_name='name',
help_text='Assigned provider'
)
class Meta:
model = ProviderNetwork
fields = [
'provider', 'name', 'description', 'comments',
]
class ProviderNetworkBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=ProviderNetwork.objects.all(),
widget=forms.MultipleHiddenInput
)
provider = DynamicModelChoiceField(
queryset=Provider.objects.all(),
required=False
)
description = forms.CharField(
max_length=100,
required=False
)
comments = CommentField(
widget=SmallTextarea,
label='Comments'
)
class Meta:
nullable_fields = [
'description', 'comments',
]
class ProviderNetworkFilterForm(BootstrapMixin, 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,
label=_('Provider'),
fetch_trigger='open'
)
tag = TagFilterField(model)
#
# Circuit types
#
class CircuitTypeForm(BootstrapMixin, CustomFieldModelForm):
slug = SlugField()
class Meta:
model = CircuitType
fields = [
'name', 'slug', 'description',
]
class CircuitTypeBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=CircuitType.objects.all(),
widget=forms.MultipleHiddenInput
)
description = forms.CharField(
max_length=200,
required=False
)
class Meta:
nullable_fields = ['description']
class CircuitTypeCSVForm(CustomFieldModelCSVForm):
slug = SlugField()
class Meta:
model = CircuitType
fields = ('name', 'slug', 'description')
help_texts = {
'name': 'Name of circuit type',
}
#
# Circuits
#
class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
provider = DynamicModelChoiceField(
queryset=Provider.objects.all()
)
type = DynamicModelChoiceField(
queryset=CircuitType.objects.all()
)
comments = CommentField()
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta:
model = Circuit
fields = [
'cid', 'type', 'provider', 'status', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant',
'comments', 'tags',
]
fieldsets = (
('Circuit', ('provider', 'cid', 'type', 'status', 'install_date', 'commit_rate', 'description', 'tags')),
('Tenancy', ('tenant_group', 'tenant')),
)
help_texts = {
'cid': "Unique circuit ID",
'commit_rate': "Committed rate",
}
widgets = {
'status': StaticSelect(),
'install_date': DatePicker(),
'commit_rate': SelectSpeedWidget(),
}
class CircuitCSVForm(CustomFieldModelCSVForm):
provider = CSVModelChoiceField(
queryset=Provider.objects.all(),
to_field_name='name',
help_text='Assigned provider'
)
type = CSVModelChoiceField(
queryset=CircuitType.objects.all(),
to_field_name='name',
help_text='Type of circuit'
)
status = CSVChoiceField(
choices=CircuitStatusChoices,
required=False,
help_text='Operational status'
)
tenant = CSVModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
to_field_name='name',
help_text='Assigned tenant'
)
class Meta:
model = Circuit
fields = [
'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
]
class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Circuit.objects.all(),
widget=forms.MultipleHiddenInput
)
type = DynamicModelChoiceField(
queryset=CircuitType.objects.all(),
required=False
)
provider = DynamicModelChoiceField(
queryset=Provider.objects.all(),
required=False
)
status = forms.ChoiceField(
choices=add_blank_choice(CircuitStatusChoices),
required=False,
initial='',
widget=StaticSelect()
)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
required=False
)
commit_rate = forms.IntegerField(
required=False,
label='Commit rate (Kbps)'
)
description = forms.CharField(
max_length=100,
required=False
)
comments = CommentField(
widget=SmallTextarea,
label='Comments'
)
class Meta:
nullable_fields = [
'tenant', 'commit_rate', 'description', 'comments',
]
class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
model = Circuit
field_groups = [
['q', 'tag'],
['provider_id', 'provider_network_id'],
['type_id', 'status', 'commit_rate'],
['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,
label=_('Type'),
fetch_trigger='open'
)
provider_id = DynamicModelMultipleChoiceField(
queryset=Provider.objects.all(),
required=False,
label=_('Provider'),
fetch_trigger='open'
)
provider_network_id = DynamicModelMultipleChoiceField(
queryset=ProviderNetwork.objects.all(),
required=False,
query_params={
'provider_id': '$provider_id'
},
label=_('Provider network'),
fetch_trigger='open'
)
status = forms.MultipleChoiceField(
choices=CircuitStatusChoices,
required=False,
widget=StaticSelectMultiple()
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
label=_('Region'),
fetch_trigger='open'
)
site_group_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
label=_('Site group'),
fetch_trigger='open'
)
site_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
required=False,
query_params={
'region_id': '$region_id',
'site_group_id': '$site_group_id',
},
label=_('Site'),
fetch_trigger='open'
)
commit_rate = forms.IntegerField(
required=False,
min_value=0,
label=_('Commit rate (Kbps)')
)
tag = TagFilterField(model)
#
# Circuit terminations
#
class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
region = DynamicModelChoiceField(
queryset=Region.objects.all(),
required=False,
initial_params={
'sites': '$site'
}
)
site_group = DynamicModelChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
initial_params={
'sites': '$site'
}
)
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
query_params={
'region_id': '$region',
'group_id': '$site_group',
},
required=False
)
provider_network = DynamicModelChoiceField(
queryset=ProviderNetwork.objects.all(),
required=False
)
class Meta:
model = CircuitTermination
fields = [
'term_side', 'region', 'site_group', 'site', 'provider_network', 'mark_connected', 'port_speed',
'upstream_speed', 'xconnect_id', 'pp_info', 'description',
]
help_texts = {
'port_speed': "Physical circuit speed",
'xconnect_id': "ID of the local cross-connect",
'pp_info': "Patch panel ID and port number(s)"
}
widgets = {
'term_side': forms.HiddenInput(),
'port_speed': SelectSpeedWidget(),
'upstream_speed': SelectSpeedWidget(),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['provider_network'].widget.add_query_param('provider_id', self.instance.circuit.provider_id)

View File

@@ -0,0 +1,4 @@
from .bulk_edit import *
from .bulk_import import *
from .filtersets import *
from .models import *

View File

@@ -0,0 +1,135 @@
from django import forms
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,
)
__all__ = (
'CircuitBulkEditForm',
'CircuitTypeBulkEditForm',
'ProviderBulkEditForm',
'ProviderNetworkBulkEditForm',
)
class ProviderBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Provider.objects.all(),
widget=forms.MultipleHiddenInput
)
asn = forms.IntegerField(
required=False,
label='ASN'
)
account = forms.CharField(
max_length=30,
required=False,
label='Account number'
)
portal_url = forms.URLField(
required=False,
label='Portal'
)
noc_contact = forms.CharField(
required=False,
widget=SmallTextarea,
label='NOC contact'
)
admin_contact = forms.CharField(
required=False,
widget=SmallTextarea,
label='Admin contact'
)
comments = CommentField(
widget=SmallTextarea,
label='Comments'
)
class Meta:
nullable_fields = [
'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments',
]
class ProviderNetworkBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=ProviderNetwork.objects.all(),
widget=forms.MultipleHiddenInput
)
provider = DynamicModelChoiceField(
queryset=Provider.objects.all(),
required=False
)
description = forms.CharField(
max_length=100,
required=False
)
comments = CommentField(
widget=SmallTextarea,
label='Comments'
)
class Meta:
nullable_fields = [
'description', 'comments',
]
class CircuitTypeBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=CircuitType.objects.all(),
widget=forms.MultipleHiddenInput
)
description = forms.CharField(
max_length=200,
required=False
)
class Meta:
nullable_fields = ['description']
class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Circuit.objects.all(),
widget=forms.MultipleHiddenInput
)
type = DynamicModelChoiceField(
queryset=CircuitType.objects.all(),
required=False
)
provider = DynamicModelChoiceField(
queryset=Provider.objects.all(),
required=False
)
status = forms.ChoiceField(
choices=add_blank_choice(CircuitStatusChoices),
required=False,
initial='',
widget=StaticSelect()
)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
required=False
)
commit_rate = forms.IntegerField(
required=False,
label='Commit rate (Kbps)'
)
description = forms.CharField(
max_length=100,
required=False
)
comments = CommentField(
widget=SmallTextarea,
label='Comments'
)
class Meta:
nullable_fields = [
'tenant', 'commit_rate', 'description', 'comments',
]

View File

@@ -0,0 +1,76 @@
from circuits.choices import CircuitStatusChoices
from circuits.models import *
from extras.forms import CustomFieldModelCSVForm
from tenancy.models import Tenant
from utilities.forms import CSVChoiceField, CSVModelChoiceField, SlugField
__all__ = (
'CircuitCSVForm',
'CircuitTypeCSVForm',
'ProviderCSVForm',
'ProviderNetworkCSVForm',
)
class ProviderCSVForm(CustomFieldModelCSVForm):
slug = SlugField()
class Meta:
model = Provider
fields = (
'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments',
)
class ProviderNetworkCSVForm(CustomFieldModelCSVForm):
provider = CSVModelChoiceField(
queryset=Provider.objects.all(),
to_field_name='name',
help_text='Assigned provider'
)
class Meta:
model = ProviderNetwork
fields = [
'provider', 'name', 'description', 'comments',
]
class CircuitTypeCSVForm(CustomFieldModelCSVForm):
slug = SlugField()
class Meta:
model = CircuitType
fields = ('name', 'slug', 'description')
help_texts = {
'name': 'Name of circuit type',
}
class CircuitCSVForm(CustomFieldModelCSVForm):
provider = CSVModelChoiceField(
queryset=Provider.objects.all(),
to_field_name='name',
help_text='Assigned provider'
)
type = CSVModelChoiceField(
queryset=CircuitType.objects.all(),
to_field_name='name',
help_text='Type of circuit'
)
status = CSVChoiceField(
choices=CircuitStatusChoices,
help_text='Operational status'
)
tenant = CSVModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
to_field_name='name',
help_text='Assigned tenant'
)
class Meta:
model = Circuit
fields = [
'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
]

View File

@@ -0,0 +1,159 @@
from django import forms
from django.utils.translation import gettext as _
from circuits.choices import CircuitStatusChoices
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
__all__ = (
'CircuitFilterForm',
'CircuitTypeFilterForm',
'ProviderFilterForm',
'ProviderNetworkFilterForm',
)
class ProviderFilterForm(BootstrapMixin, 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,
label=_('Region'),
fetch_trigger='open'
)
site_group_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
label=_('Site group'),
fetch_trigger='open'
)
site_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
required=False,
query_params={
'region_id': '$region_id',
'site_group_id': '$site_group_id',
},
label=_('Site'),
fetch_trigger='open'
)
asn = forms.IntegerField(
required=False,
label=_('ASN')
)
tag = TagFilterField(model)
class ProviderNetworkFilterForm(BootstrapMixin, 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,
label=_('Provider'),
fetch_trigger='open'
)
tag = TagFilterField(model)
class CircuitTypeFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
model = CircuitType
field_groups = [
['q'],
]
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
model = Circuit
field_groups = [
['q', 'tag'],
['provider_id', 'provider_network_id'],
['type_id', 'status', 'commit_rate'],
['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,
label=_('Type'),
fetch_trigger='open'
)
provider_id = DynamicModelMultipleChoiceField(
queryset=Provider.objects.all(),
required=False,
label=_('Provider'),
fetch_trigger='open'
)
provider_network_id = DynamicModelMultipleChoiceField(
queryset=ProviderNetwork.objects.all(),
required=False,
query_params={
'provider_id': '$provider_id'
},
label=_('Provider network'),
fetch_trigger='open'
)
status = forms.MultipleChoiceField(
choices=CircuitStatusChoices,
required=False,
widget=StaticSelectMultiple()
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
label=_('Region'),
fetch_trigger='open'
)
site_group_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
label=_('Site group'),
fetch_trigger='open'
)
site_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
required=False,
query_params={
'region_id': '$region_id',
'site_group_id': '$site_group_id',
},
label=_('Site'),
fetch_trigger='open'
)
commit_rate = forms.IntegerField(
required=False,
min_value=0,
label=_('Commit rate (Kbps)')
)
tag = TagFilterField(model)

View File

@@ -0,0 +1,168 @@
from django import forms
from circuits.models import *
from dcim.models import Region, Site, SiteGroup
from extras.forms import CustomFieldModelForm
from extras.models import Tag
from tenancy.forms import TenancyForm
from utilities.forms import (
BootstrapMixin, CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
SelectSpeedWidget, SmallTextarea, SlugField, StaticSelect,
)
__all__ = (
'CircuitForm',
'CircuitTerminationForm',
'CircuitTypeForm',
'ProviderForm',
'ProviderNetworkForm',
)
class ProviderForm(BootstrapMixin, CustomFieldModelForm):
slug = SlugField()
comments = CommentField()
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta:
model = Provider
fields = [
'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'tags',
]
fieldsets = (
('Provider', ('name', 'slug', 'asn', 'tags')),
('Support Info', ('account', 'portal_url', 'noc_contact', 'admin_contact')),
)
widgets = {
'noc_contact': SmallTextarea(
attrs={'rows': 5}
),
'admin_contact': SmallTextarea(
attrs={'rows': 5}
),
}
help_texts = {
'name': "Full name of the provider",
'asn': "BGP autonomous system number (if applicable)",
'portal_url': "URL of the provider's customer support portal",
'noc_contact': "NOC email address and phone number",
'admin_contact': "Administrative contact email address and phone number",
}
class ProviderNetworkForm(BootstrapMixin, CustomFieldModelForm):
provider = DynamicModelChoiceField(
queryset=Provider.objects.all()
)
comments = CommentField()
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta:
model = ProviderNetwork
fields = [
'provider', 'name', 'description', 'comments', 'tags',
]
fieldsets = (
('Provider Network', ('provider', 'name', 'description', 'tags')),
)
class CircuitTypeForm(BootstrapMixin, CustomFieldModelForm):
slug = SlugField()
class Meta:
model = CircuitType
fields = [
'name', 'slug', 'description',
]
class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
provider = DynamicModelChoiceField(
queryset=Provider.objects.all()
)
type = DynamicModelChoiceField(
queryset=CircuitType.objects.all()
)
comments = CommentField()
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta:
model = Circuit
fields = [
'cid', 'type', 'provider', 'status', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant',
'comments', 'tags',
]
fieldsets = (
('Circuit', ('provider', 'cid', 'type', 'status', 'install_date', 'commit_rate', 'description', 'tags')),
('Tenancy', ('tenant_group', 'tenant')),
)
help_texts = {
'cid': "Unique circuit ID",
'commit_rate': "Committed rate",
}
widgets = {
'status': StaticSelect(),
'install_date': DatePicker(),
'commit_rate': SelectSpeedWidget(),
}
class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
region = DynamicModelChoiceField(
queryset=Region.objects.all(),
required=False,
initial_params={
'sites': '$site'
}
)
site_group = DynamicModelChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
initial_params={
'sites': '$site'
}
)
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
query_params={
'region_id': '$region',
'group_id': '$site_group',
},
required=False
)
provider_network = DynamicModelChoiceField(
queryset=ProviderNetwork.objects.all(),
required=False
)
class Meta:
model = CircuitTermination
fields = [
'term_side', 'region', 'site_group', 'site', 'provider_network', 'mark_connected', 'port_speed',
'upstream_speed', 'xconnect_id', 'pp_info', 'description',
]
help_texts = {
'port_speed': "Physical circuit speed",
'xconnect_id': "ID of the local cross-connect",
'pp_info': "Patch panel ID and port number(s)"
}
widgets = {
'term_side': forms.HiddenInput(),
'port_speed': SelectSpeedWidget(),
'upstream_speed': SelectSpeedWidget(),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['provider_network'].widget.add_query_param('provider_id', self.instance.circuit.provider_id)

View File

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

View File

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

View File

@@ -2,10 +2,18 @@ import django_tables2 as tables
from django_tables2.utils import Accessor
from tenancy.tables import TenantColumn
from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, TagColumn, ToggleColumn
from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, MarkdownColumn, TagColumn, ToggleColumn
from .models import *
__all__ = (
'CircuitTable',
'CircuitTypeTable',
'ProviderTable',
'ProviderNetworkTable',
)
CIRCUITTERMINATION_LINK = """
{% if value.site %}
<a href="{{ value.site.get_absolute_url }}">{{ value.site }}</a>
@@ -28,6 +36,7 @@ class ProviderTable(BaseTable):
accessor=Accessor('count_circuits'),
verbose_name='Circuits'
)
comments = MarkdownColumn()
tags = TagColumn(
url_name='circuits:provider_list'
)
@@ -35,7 +44,8 @@ class ProviderTable(BaseTable):
class Meta(BaseTable.Meta):
model = Provider
fields = (
'pk', 'name', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'circuit_count', 'tags',
'pk', 'id', 'name', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'circuit_count',
'comments', 'tags',
)
default_columns = ('pk', 'name', 'asn', 'account', 'circuit_count')
@@ -52,13 +62,14 @@ class ProviderNetworkTable(BaseTable):
provider = tables.Column(
linkify=True
)
comments = MarkdownColumn()
tags = TagColumn(
url_name='circuits:providernetwork_list'
)
class Meta(BaseTable.Meta):
model = ProviderNetwork
fields = ('pk', 'name', 'provider', 'description', 'tags')
fields = ('pk', 'id', 'name', 'provider', 'description', 'comments', 'tags')
default_columns = ('pk', 'name', 'provider', 'description')
@@ -78,7 +89,7 @@ class CircuitTypeTable(BaseTable):
class Meta(BaseTable.Meta):
model = CircuitType
fields = ('pk', 'name', 'circuit_count', 'description', 'slug', 'actions')
fields = ('pk', 'id', 'name', 'circuit_count', 'description', 'slug', 'actions')
default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug', 'actions')
@@ -90,7 +101,7 @@ class CircuitTable(BaseTable):
pk = ToggleColumn()
cid = tables.Column(
linkify=True,
verbose_name='ID'
verbose_name='Circuit ID'
)
provider = tables.Column(
linkify=True
@@ -105,6 +116,7 @@ class CircuitTable(BaseTable):
template_code=CIRCUITTERMINATION_LINK,
verbose_name='Side Z'
)
comments = MarkdownColumn()
tags = TagColumn(
url_name='circuits:circuit_list'
)
@@ -112,8 +124,8 @@ class CircuitTable(BaseTable):
class Meta(BaseTable.Meta):
model = Circuit
fields = (
'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'install_date',
'commit_rate', 'description', 'tags',
'pk', 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'install_date',
'commit_rate', 'description', 'comments', 'tags',
)
default_columns = (
'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description',

View File

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

View File

@@ -122,10 +122,10 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
cls.csv_data = (
"cid,provider,type",
"Circuit 4,Provider 1,Circuit Type 1",
"Circuit 5,Provider 1,Circuit Type 1",
"Circuit 6,Provider 1,Circuit Type 1",
"cid,provider,type,status",
"Circuit 4,Provider 1,Circuit Type 1,active",
"Circuit 5,Provider 1,Circuit Type 1,active",
"Circuit 6,Provider 1,Circuit Type 1,active",
)
cls.bulk_edit_data = {

View File

@@ -34,9 +34,7 @@ class ProviderView(generic.ObjectView):
).prefetch_related(
'type', 'tenant', 'terminations__site'
)
circuits_table = tables.CircuitTable(circuits)
circuits_table.columns.hide('provider')
circuits_table = tables.CircuitTable(circuits, exclude=('provider',))
paginate_table(circuits_table, request)
return {
@@ -97,10 +95,7 @@ class ProviderNetworkView(generic.ObjectView):
).prefetch_related(
'type', 'tenant', 'terminations__site'
)
circuits_table = tables.CircuitTable(circuits)
circuits_table.columns.hide('termination_a')
circuits_table.columns.hide('termination_z')
paginate_table(circuits_table, request)
return {
@@ -144,6 +139,8 @@ class CircuitTypeListView(generic.ObjectListView):
queryset = CircuitType.objects.annotate(
circuit_count=count_related(Circuit, 'type')
)
filterset = filtersets.CircuitTypeFilterSet
filterset_form = forms.CircuitTypeFilterForm
table = tables.CircuitTypeTable
@@ -151,12 +148,8 @@ class CircuitTypeView(generic.ObjectView):
queryset = CircuitType.objects.all()
def get_extra_context(self, request, instance):
circuits = Circuit.objects.restrict(request.user, 'view').filter(
type=instance
)
circuits_table = tables.CircuitTable(circuits)
circuits_table.columns.hide('type')
circuits = Circuit.objects.restrict(request.user, 'view').filter(type=instance)
circuits_table = tables.CircuitTable(circuits, exclude=('type',))
paginate_table(circuits_table, request)
return {

View File

@@ -1 +0,0 @@
default_app_config = 'dcim.apps.DCIMConfig'

View File

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

View File

@@ -192,6 +192,7 @@ class ConsolePortTypeChoices(ChoiceSet):
TYPE_USB_MINI_B = 'usb-mini-b'
TYPE_USB_MICRO_A = 'usb-micro-a'
TYPE_USB_MICRO_B = 'usb-micro-b'
TYPE_USB_MICRO_AB = 'usb-micro-ab'
TYPE_OTHER = 'other'
CHOICES = (
@@ -210,6 +211,7 @@ class ConsolePortTypeChoices(ChoiceSet):
(TYPE_USB_MINI_B, 'USB Mini B'),
(TYPE_USB_MICRO_A, 'USB Micro A'),
(TYPE_USB_MICRO_B, 'USB Micro B'),
(TYPE_USB_MICRO_AB, 'USB Micro AB'),
)),
('Other', (
(TYPE_OTHER, 'Other'),
@@ -316,6 +318,7 @@ class PowerPortTypeChoices(ChoiceSet):
TYPE_CS8365C = 'cs8365c'
TYPE_CS8465C = 'cs8465c'
# ITA/international
TYPE_ITA_C = 'ita-c'
TYPE_ITA_E = 'ita-e'
TYPE_ITA_F = 'ita-f'
TYPE_ITA_EF = 'ita-ef'
@@ -336,6 +339,7 @@ class PowerPortTypeChoices(ChoiceSet):
TYPE_USB_MINI_B = 'usb-mini-b'
TYPE_USB_MICRO_A = 'usb-micro-a'
TYPE_USB_MICRO_B = 'usb-micro-b'
TYPE_USB_MICRO_AB = 'usb-micro-ab'
TYPE_USB_3_B = 'usb-3-b'
TYPE_USB_3_MICROB = 'usb-3-micro-b'
# Direct current (DC)
@@ -421,6 +425,7 @@ class PowerPortTypeChoices(ChoiceSet):
(TYPE_CS8465C, 'CS8465C'),
)),
('International/ITA', (
(TYPE_ITA_C, 'ITA Type C (CEE 7/16)'),
(TYPE_ITA_E, 'ITA Type E (CEE 7/5)'),
(TYPE_ITA_F, 'ITA Type F (CEE 7/4)'),
(TYPE_ITA_EF, 'ITA Type E/F (CEE 7/7)'),
@@ -442,6 +447,7 @@ class PowerPortTypeChoices(ChoiceSet):
(TYPE_USB_MINI_B, 'USB Mini B'),
(TYPE_USB_MICRO_A, 'USB Micro A'),
(TYPE_USB_MICRO_B, 'USB Micro B'),
(TYPE_USB_MICRO_AB, 'USB Micro AB'),
(TYPE_USB_3_B, 'USB 3.0 Type B'),
(TYPE_USB_3_MICROB, 'USB 3.0 Micro B'),
)),
@@ -679,6 +685,18 @@ class PowerOutletFeedLegChoices(ChoiceSet):
# Interfaces
#
class InterfaceKindChoices(ChoiceSet):
KIND_PHYSICAL = 'physical'
KIND_VIRTUAL = 'virtual'
KIND_WIRELESS = 'wireless'
CHOICES = (
(KIND_PHYSICAL, 'Physical'),
(KIND_VIRTUAL, 'Virtual'),
(KIND_WIRELESS, 'Wireless'),
)
class InterfaceTypeChoices(ChoiceSet):
# Virtual
@@ -761,6 +779,9 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_T3 = 't3'
TYPE_E3 = 'e3'
# ATM/DSL
TYPE_XDSL = 'xdsl'
# Stacking
TYPE_STACKWISE = 'cisco-stackwise'
TYPE_STACKWISE_PLUS = 'cisco-stackwise-plus'
@@ -885,6 +906,12 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_E3, 'E3 (34 Mbps)'),
)
),
(
'ATM',
(
(TYPE_XDSL, 'xDSL'),
)
),
(
'Stacking',
(
@@ -958,6 +985,11 @@ class PortTypeChoices(ChoiceSet):
TYPE_SPLICE = 'splice'
TYPE_CS = 'cs'
TYPE_SN = 'sn'
TYPE_SMA_905 = 'sma-905'
TYPE_SMA_906 = 'sma-906'
TYPE_URM_P2 = 'urm-p2'
TYPE_URM_P4 = 'urm-p4'
TYPE_URM_P8 = 'urm-p8'
CHOICES = (
(
@@ -998,6 +1030,11 @@ class PortTypeChoices(ChoiceSet):
(TYPE_ST, 'ST'),
(TYPE_CS, 'CS'),
(TYPE_SN, 'SN'),
(TYPE_SMA_905, 'SMA 905'),
(TYPE_SMA_906, 'SMA 906'),
(TYPE_URM_P2, 'URM-P2'),
(TYPE_URM_P4, 'URM-P4'),
(TYPE_URM_P8, 'URM-P8'),
(TYPE_SPLICE, 'Splice'),
)
)

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,10 @@
from .fields import *
from .models import *
from .filtersets import *
from .object_create import *
from .object_import import *
from .bulk_create import *
from .bulk_edit import *
from .bulk_import import *
from .connections import *
from .formsets import *

View File

@@ -0,0 +1,111 @@
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 .object_create import ComponentForm
__all__ = (
'ConsolePortBulkCreateForm',
'ConsoleServerPortBulkCreateForm',
'DeviceBayBulkCreateForm',
# 'FrontPortBulkCreateForm',
'InterfaceBulkCreateForm',
'InventoryItemBulkCreateForm',
'PowerOutletBulkCreateForm',
'PowerPortBulkCreateForm',
'RearPortBulkCreateForm',
)
#
# Device components
#
class DeviceBulkAddComponentForm(BootstrapMixin, CustomFieldsMixin, ComponentForm):
pk = forms.ModelMultipleChoiceField(
queryset=Device.objects.all(),
widget=forms.MultipleHiddenInput()
)
description = forms.CharField(
max_length=100,
required=False
)
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class ConsolePortBulkCreateForm(
form_from_model(ConsolePort, ['type', 'speed', 'mark_connected']),
DeviceBulkAddComponentForm
):
model = ConsolePort
field_order = ('name_pattern', 'label_pattern', 'type', 'mark_connected', 'description', 'tags')
class ConsoleServerPortBulkCreateForm(
form_from_model(ConsoleServerPort, ['type', 'speed', 'mark_connected']),
DeviceBulkAddComponentForm
):
model = ConsoleServerPort
field_order = ('name_pattern', 'label_pattern', 'type', 'speed', 'description', 'tags')
class PowerPortBulkCreateForm(
form_from_model(PowerPort, ['type', 'maximum_draw', 'allocated_draw', 'mark_connected']),
DeviceBulkAddComponentForm
):
model = PowerPort
field_order = ('name_pattern', 'label_pattern', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags')
class PowerOutletBulkCreateForm(
form_from_model(PowerOutlet, ['type', 'feed_leg', 'mark_connected']),
DeviceBulkAddComponentForm
):
model = PowerOutlet
field_order = ('name_pattern', 'label_pattern', 'type', 'feed_leg', 'description', 'tags')
class InterfaceBulkCreateForm(
form_from_model(Interface, ['type', 'enabled', 'mtu', 'mgmt_only', 'mark_connected']),
DeviceBulkAddComponentForm
):
model = Interface
field_order = (
'name_pattern', 'label_pattern', 'type', 'enabled', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'tags',
)
# class FrontPortBulkCreateForm(
# form_from_model(FrontPort, ['label', 'type', 'description', 'tags']),
# DeviceBulkAddComponentForm
# ):
# pass
class RearPortBulkCreateForm(
form_from_model(RearPort, ['type', 'color', 'positions', 'mark_connected']),
DeviceBulkAddComponentForm
):
model = RearPort
field_order = ('name_pattern', 'label_pattern', 'type', 'positions', 'mark_connected', 'description', 'tags')
class DeviceBayBulkCreateForm(DeviceBulkAddComponentForm):
model = DeviceBay
field_order = ('name_pattern', 'label_pattern', 'description', 'tags')
class InventoryItemBulkCreateForm(
form_from_model(InventoryItem, ['manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered']),
DeviceBulkAddComponentForm
):
model = InventoryItem
field_order = (
'name_pattern', 'label_pattern', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description',
'tags',
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,970 @@
from django import forms
from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.forms.array import SimpleArrayField
from django.core.exceptions import ObjectDoesNotExist
from django.utils.safestring import mark_safe
from dcim.choices import *
from dcim.constants import *
from dcim.models import *
from extras.forms import CustomFieldModelCSVForm
from tenancy.models import Tenant
from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, SlugField
from virtualization.models import Cluster
__all__ = (
'CableCSVForm',
'ChildDeviceCSVForm',
'ConsolePortCSVForm',
'ConsoleServerPortCSVForm',
'DeviceBayCSVForm',
'DeviceCSVForm',
'DeviceRoleCSVForm',
'FrontPortCSVForm',
'InterfaceCSVForm',
'InventoryItemCSVForm',
'LocationCSVForm',
'ManufacturerCSVForm',
'PlatformCSVForm',
'PowerFeedCSVForm',
'PowerOutletCSVForm',
'PowerPanelCSVForm',
'PowerPortCSVForm',
'RackCSVForm',
'RackReservationCSVForm',
'RackRoleCSVForm',
'RearPortCSVForm',
'RegionCSVForm',
'SiteCSVForm',
'SiteGroupCSVForm',
'VirtualChassisCSVForm',
)
class RegionCSVForm(CustomFieldModelCSVForm):
parent = CSVModelChoiceField(
queryset=Region.objects.all(),
required=False,
to_field_name='name',
help_text='Name of parent region'
)
class Meta:
model = Region
fields = ('name', 'slug', 'parent', 'description')
class SiteGroupCSVForm(CustomFieldModelCSVForm):
parent = CSVModelChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
to_field_name='name',
help_text='Name of parent site group'
)
class Meta:
model = SiteGroup
fields = ('name', 'slug', 'parent', 'description')
class SiteCSVForm(CustomFieldModelCSVForm):
status = CSVChoiceField(
choices=SiteStatusChoices,
help_text='Operational status'
)
region = CSVModelChoiceField(
queryset=Region.objects.all(),
required=False,
to_field_name='name',
help_text='Assigned region'
)
group = CSVModelChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
to_field_name='name',
help_text='Assigned group'
)
tenant = CSVModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
to_field_name='name',
help_text='Assigned tenant'
)
class Meta:
model = Site
fields = (
'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'asn', 'time_zone', 'description',
'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone',
'contact_email', 'comments',
)
help_texts = {
'time_zone': mark_safe(
'Time zone (<a href="https://en.wikipedia.org/wiki/List_of_tz_database_time_zones">available options</a>)'
)
}
class LocationCSVForm(CustomFieldModelCSVForm):
site = CSVModelChoiceField(
queryset=Site.objects.all(),
to_field_name='name',
help_text='Assigned site'
)
parent = CSVModelChoiceField(
queryset=Location.objects.all(),
required=False,
to_field_name='name',
help_text='Parent location',
error_messages={
'invalid_choice': 'Location not found.',
}
)
class Meta:
model = Location
fields = ('site', 'parent', 'name', 'slug', 'description')
class RackRoleCSVForm(CustomFieldModelCSVForm):
slug = SlugField()
class Meta:
model = RackRole
fields = ('name', 'slug', 'color', 'description')
help_texts = {
'color': mark_safe('RGB color in hexadecimal (e.g. <code>00ff00</code>)'),
}
class RackCSVForm(CustomFieldModelCSVForm):
site = CSVModelChoiceField(
queryset=Site.objects.all(),
to_field_name='name'
)
location = CSVModelChoiceField(
queryset=Location.objects.all(),
required=False,
to_field_name='name'
)
tenant = CSVModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
to_field_name='name',
help_text='Name of assigned tenant'
)
status = CSVChoiceField(
choices=RackStatusChoices,
help_text='Operational status'
)
role = CSVModelChoiceField(
queryset=RackRole.objects.all(),
required=False,
to_field_name='name',
help_text='Name of assigned role'
)
type = CSVChoiceField(
choices=RackTypeChoices,
required=False,
help_text='Rack type'
)
width = forms.ChoiceField(
choices=RackWidthChoices,
help_text='Rail-to-rail width (in inches)'
)
outer_unit = CSVChoiceField(
choices=RackDimensionUnitChoices,
required=False,
help_text='Unit for outer dimensions'
)
class Meta:
model = Rack
fields = (
'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag',
'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'comments',
)
def __init__(self, data=None, *args, **kwargs):
super().__init__(data, *args, **kwargs)
if data:
# Limit location queryset by assigned site
params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
class RackReservationCSVForm(CustomFieldModelCSVForm):
site = CSVModelChoiceField(
queryset=Site.objects.all(),
to_field_name='name',
help_text='Parent site'
)
location = CSVModelChoiceField(
queryset=Location.objects.all(),
to_field_name='name',
required=False,
help_text="Rack's location (if any)"
)
rack = CSVModelChoiceField(
queryset=Rack.objects.all(),
to_field_name='name',
help_text='Rack'
)
units = SimpleArrayField(
base_field=forms.IntegerField(),
required=True,
help_text='Comma-separated list of individual unit numbers'
)
tenant = CSVModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
to_field_name='name',
help_text='Assigned tenant'
)
class Meta:
model = RackReservation
fields = ('site', 'location', 'rack', 'units', 'tenant', 'description')
def __init__(self, data=None, *args, **kwargs):
super().__init__(data, *args, **kwargs)
if data:
# Limit location queryset by assigned site
params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
# Limit rack queryset by assigned site and group
params = {
f"site__{self.fields['site'].to_field_name}": data.get('site'),
f"location__{self.fields['location'].to_field_name}": data.get('location'),
}
self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
class ManufacturerCSVForm(CustomFieldModelCSVForm):
class Meta:
model = Manufacturer
fields = ('name', 'slug', 'description')
class DeviceRoleCSVForm(CustomFieldModelCSVForm):
slug = SlugField()
class Meta:
model = DeviceRole
fields = ('name', 'slug', 'color', 'vm_role', 'description')
help_texts = {
'color': mark_safe('RGB color in hexadecimal (e.g. <code>00ff00</code>)'),
}
class PlatformCSVForm(CustomFieldModelCSVForm):
slug = SlugField()
manufacturer = CSVModelChoiceField(
queryset=Manufacturer.objects.all(),
required=False,
to_field_name='name',
help_text='Limit platform assignments to this manufacturer'
)
class Meta:
model = Platform
fields = ('name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description')
class BaseDeviceCSVForm(CustomFieldModelCSVForm):
device_role = CSVModelChoiceField(
queryset=DeviceRole.objects.all(),
to_field_name='name',
help_text='Assigned role'
)
tenant = CSVModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
to_field_name='name',
help_text='Assigned tenant'
)
manufacturer = CSVModelChoiceField(
queryset=Manufacturer.objects.all(),
to_field_name='name',
help_text='Device type manufacturer'
)
device_type = CSVModelChoiceField(
queryset=DeviceType.objects.all(),
to_field_name='model',
help_text='Device type model'
)
platform = CSVModelChoiceField(
queryset=Platform.objects.all(),
required=False,
to_field_name='name',
help_text='Assigned platform'
)
status = CSVChoiceField(
choices=DeviceStatusChoices,
help_text='Operational status'
)
virtual_chassis = CSVModelChoiceField(
queryset=VirtualChassis.objects.all(),
to_field_name='name',
required=False,
help_text='Virtual chassis'
)
cluster = CSVModelChoiceField(
queryset=Cluster.objects.all(),
to_field_name='name',
required=False,
help_text='Virtualization cluster'
)
class Meta:
fields = []
model = Device
help_texts = {
'vc_position': 'Virtual chassis position',
'vc_priority': 'Virtual chassis priority',
}
def __init__(self, data=None, *args, **kwargs):
super().__init__(data, *args, **kwargs)
if data:
# Limit device type queryset by manufacturer
params = {f"manufacturer__{self.fields['manufacturer'].to_field_name}": data.get('manufacturer')}
self.fields['device_type'].queryset = self.fields['device_type'].queryset.filter(**params)
class DeviceCSVForm(BaseDeviceCSVForm):
site = CSVModelChoiceField(
queryset=Site.objects.all(),
to_field_name='name',
help_text='Assigned site'
)
location = CSVModelChoiceField(
queryset=Location.objects.all(),
to_field_name='name',
required=False,
help_text="Assigned location (if any)"
)
rack = CSVModelChoiceField(
queryset=Rack.objects.all(),
to_field_name='name',
required=False,
help_text="Assigned rack (if any)"
)
face = CSVChoiceField(
choices=DeviceFaceChoices,
required=False,
help_text='Mounted rack face'
)
class Meta(BaseDeviceCSVForm.Meta):
fields = [
'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
'site', 'location', 'rack', 'position', 'face', 'virtual_chassis', 'vc_position', 'vc_priority', 'cluster',
'comments',
]
def __init__(self, data=None, *args, **kwargs):
super().__init__(data, *args, **kwargs)
if data:
# Limit location queryset by assigned site
params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
# Limit rack queryset by assigned site and group
params = {
f"site__{self.fields['site'].to_field_name}": data.get('site'),
f"location__{self.fields['location'].to_field_name}": data.get('location'),
}
self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
class ChildDeviceCSVForm(BaseDeviceCSVForm):
parent = CSVModelChoiceField(
queryset=Device.objects.all(),
to_field_name='name',
help_text='Parent device'
)
device_bay = CSVModelChoiceField(
queryset=DeviceBay.objects.all(),
to_field_name='name',
help_text='Device bay in which this device is installed'
)
class Meta(BaseDeviceCSVForm.Meta):
fields = [
'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
'parent', 'device_bay', 'virtual_chassis', 'vc_position', 'vc_priority', 'cluster', 'comments',
]
def __init__(self, data=None, *args, **kwargs):
super().__init__(data, *args, **kwargs)
if data:
# Limit device bay queryset by parent device
params = {f"device__{self.fields['parent'].to_field_name}": data.get('parent')}
self.fields['device_bay'].queryset = self.fields['device_bay'].queryset.filter(**params)
def clean(self):
super().clean()
# Set parent_bay reverse relationship
device_bay = self.cleaned_data.get('device_bay')
if device_bay:
self.instance.parent_bay = device_bay
# Inherit site and rack from parent device
parent = self.cleaned_data.get('parent')
if parent:
self.instance.site = parent.site
self.instance.rack = parent.rack
#
# Device components
#
class ConsolePortCSVForm(CustomFieldModelCSVForm):
device = CSVModelChoiceField(
queryset=Device.objects.all(),
to_field_name='name'
)
type = CSVChoiceField(
choices=ConsolePortTypeChoices,
required=False,
help_text='Port type'
)
speed = CSVTypedChoiceField(
choices=ConsolePortSpeedChoices,
coerce=int,
empty_value=None,
required=False,
help_text='Port speed in bps'
)
class Meta:
model = ConsolePort
fields = ('device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description')
class ConsoleServerPortCSVForm(CustomFieldModelCSVForm):
device = CSVModelChoiceField(
queryset=Device.objects.all(),
to_field_name='name'
)
type = CSVChoiceField(
choices=ConsolePortTypeChoices,
required=False,
help_text='Port type'
)
speed = CSVTypedChoiceField(
choices=ConsolePortSpeedChoices,
coerce=int,
empty_value=None,
required=False,
help_text='Port speed in bps'
)
class Meta:
model = ConsoleServerPort
fields = ('device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description')
class PowerPortCSVForm(CustomFieldModelCSVForm):
device = CSVModelChoiceField(
queryset=Device.objects.all(),
to_field_name='name'
)
type = CSVChoiceField(
choices=PowerPortTypeChoices,
required=False,
help_text='Port type'
)
class Meta:
model = PowerPort
fields = (
'device', 'name', 'label', 'type', 'mark_connected', 'maximum_draw', 'allocated_draw', 'description',
)
class PowerOutletCSVForm(CustomFieldModelCSVForm):
device = CSVModelChoiceField(
queryset=Device.objects.all(),
to_field_name='name'
)
type = CSVChoiceField(
choices=PowerOutletTypeChoices,
required=False,
help_text='Outlet type'
)
power_port = CSVModelChoiceField(
queryset=PowerPort.objects.all(),
required=False,
to_field_name='name',
help_text='Local power port which feeds this outlet'
)
feed_leg = CSVChoiceField(
choices=PowerOutletFeedLegChoices,
required=False,
help_text='Electrical phase (for three-phase circuits)'
)
class Meta:
model = PowerOutlet
fields = ('device', 'name', 'label', 'type', 'mark_connected', 'power_port', 'feed_leg', 'description')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Limit PowerPort choices to those belonging to this device (or VC master)
if self.is_bound:
try:
device = self.fields['device'].to_python(self.data['device'])
except forms.ValidationError:
device = None
else:
try:
device = self.instance.device
except Device.DoesNotExist:
device = None
if device:
self.fields['power_port'].queryset = PowerPort.objects.filter(
device__in=[device, device.get_vc_master()]
)
else:
self.fields['power_port'].queryset = PowerPort.objects.none()
class InterfaceCSVForm(CustomFieldModelCSVForm):
device = CSVModelChoiceField(
queryset=Device.objects.all(),
to_field_name='name'
)
parent = CSVModelChoiceField(
queryset=Interface.objects.all(),
required=False,
to_field_name='name',
help_text='Parent interface'
)
lag = CSVModelChoiceField(
queryset=Interface.objects.all(),
required=False,
to_field_name='name',
help_text='Parent LAG interface'
)
type = CSVChoiceField(
choices=InterfaceTypeChoices,
help_text='Physical medium'
)
mode = CSVChoiceField(
choices=InterfaceModeChoices,
required=False,
help_text='IEEE 802.1Q operational mode (for L2 interfaces)'
)
class Meta:
model = Interface
fields = (
'device', 'name', 'label', 'parent', 'lag', 'type', 'enabled', 'mark_connected', 'mac_address', 'mtu',
'mgmt_only', 'description', 'mode',
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Limit LAG choices to interfaces belonging to this device (or virtual chassis)
device = None
if self.is_bound and 'device' in self.data:
try:
device = self.fields['device'].to_python(self.data['device'])
except forms.ValidationError:
pass
if device and device.virtual_chassis:
self.fields['lag'].queryset = Interface.objects.filter(
Q(device=device) | Q(device__virtual_chassis=device.virtual_chassis),
type=InterfaceTypeChoices.TYPE_LAG
)
self.fields['parent'].queryset = Interface.objects.filter(
Q(device=device) | Q(device__virtual_chassis=device.virtual_chassis)
)
elif device:
self.fields['lag'].queryset = Interface.objects.filter(
device=device,
type=InterfaceTypeChoices.TYPE_LAG
)
self.fields['parent'].queryset = Interface.objects.filter(device=device)
else:
self.fields['lag'].queryset = Interface.objects.none()
self.fields['parent'].queryset = Interface.objects.none()
def clean_enabled(self):
# Make sure enabled is True when it's not included in the uploaded data
if 'enabled' not in self.data:
return True
else:
return self.cleaned_data['enabled']
class FrontPortCSVForm(CustomFieldModelCSVForm):
device = CSVModelChoiceField(
queryset=Device.objects.all(),
to_field_name='name'
)
rear_port = CSVModelChoiceField(
queryset=RearPort.objects.all(),
to_field_name='name',
help_text='Corresponding rear port'
)
type = CSVChoiceField(
choices=PortTypeChoices,
help_text='Physical medium classification'
)
class Meta:
model = FrontPort
fields = (
'device', 'name', 'label', 'type', 'color', 'mark_connected', 'rear_port', 'rear_port_position',
'description',
)
help_texts = {
'rear_port_position': 'Mapped position on corresponding rear port',
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Limit RearPort choices to those belonging to this device (or VC master)
if self.is_bound:
try:
device = self.fields['device'].to_python(self.data['device'])
except forms.ValidationError:
device = None
else:
try:
device = self.instance.device
except Device.DoesNotExist:
device = None
if device:
self.fields['rear_port'].queryset = RearPort.objects.filter(
device__in=[device, device.get_vc_master()]
)
else:
self.fields['rear_port'].queryset = RearPort.objects.none()
class RearPortCSVForm(CustomFieldModelCSVForm):
device = CSVModelChoiceField(
queryset=Device.objects.all(),
to_field_name='name'
)
type = CSVChoiceField(
help_text='Physical medium classification',
choices=PortTypeChoices,
)
class Meta:
model = RearPort
fields = ('device', 'name', 'label', 'type', 'color', 'mark_connected', 'positions', 'description')
help_texts = {
'positions': 'Number of front ports which may be mapped'
}
class DeviceBayCSVForm(CustomFieldModelCSVForm):
device = CSVModelChoiceField(
queryset=Device.objects.all(),
to_field_name='name'
)
installed_device = CSVModelChoiceField(
queryset=Device.objects.all(),
required=False,
to_field_name='name',
help_text='Child device installed within this bay',
error_messages={
'invalid_choice': 'Child device not found.',
}
)
class Meta:
model = DeviceBay
fields = ('device', 'name', 'label', 'installed_device', 'description')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Limit installed device choices to devices of the correct type and location
if self.is_bound:
try:
device = self.fields['device'].to_python(self.data['device'])
except forms.ValidationError:
device = None
else:
try:
device = self.instance.device
except Device.DoesNotExist:
device = None
if device:
self.fields['installed_device'].queryset = Device.objects.filter(
site=device.site,
rack=device.rack,
parent_bay__isnull=True,
device_type__u_height=0,
device_type__subdevice_role=SubdeviceRoleChoices.ROLE_CHILD
).exclude(pk=device.pk)
else:
self.fields['installed_device'].queryset = Interface.objects.none()
class InventoryItemCSVForm(CustomFieldModelCSVForm):
device = CSVModelChoiceField(
queryset=Device.objects.all(),
to_field_name='name'
)
manufacturer = CSVModelChoiceField(
queryset=Manufacturer.objects.all(),
to_field_name='name',
required=False
)
parent = CSVModelChoiceField(
queryset=Device.objects.all(),
to_field_name='name',
required=False,
help_text='Parent inventory item'
)
class Meta:
model = InventoryItem
fields = (
'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description',
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Limit parent choices to inventory items belonging to this device
device = None
if self.is_bound and 'device' in self.data:
try:
device = self.fields['device'].to_python(self.data['device'])
except forms.ValidationError:
pass
if device:
self.fields['parent'].queryset = InventoryItem.objects.filter(device=device)
else:
self.fields['parent'].queryset = InventoryItem.objects.none()
class CableCSVForm(CustomFieldModelCSVForm):
# Termination A
side_a_device = CSVModelChoiceField(
queryset=Device.objects.all(),
to_field_name='name',
help_text='Side A device'
)
side_a_type = CSVContentTypeField(
queryset=ContentType.objects.all(),
limit_choices_to=CABLE_TERMINATION_MODELS,
help_text='Side A type'
)
side_a_name = forms.CharField(
help_text='Side A component name'
)
# Termination B
side_b_device = CSVModelChoiceField(
queryset=Device.objects.all(),
to_field_name='name',
help_text='Side B device'
)
side_b_type = CSVContentTypeField(
queryset=ContentType.objects.all(),
limit_choices_to=CABLE_TERMINATION_MODELS,
help_text='Side B type'
)
side_b_name = forms.CharField(
help_text='Side B component name'
)
# Cable attributes
status = CSVChoiceField(
choices=CableStatusChoices,
required=False,
help_text='Connection status'
)
type = CSVChoiceField(
choices=CableTypeChoices,
required=False,
help_text='Physical medium classification'
)
length_unit = CSVChoiceField(
choices=CableLengthUnitChoices,
required=False,
help_text='Length unit'
)
class Meta:
model = Cable
fields = [
'side_a_device', 'side_a_type', 'side_a_name', 'side_b_device', 'side_b_type', 'side_b_name', 'type',
'status', 'label', 'color', 'length', 'length_unit',
]
help_texts = {
'color': mark_safe('RGB color in hexadecimal (e.g. <code>00ff00</code>)'),
}
def _clean_side(self, side):
"""
Derive a Cable's A/B termination objects.
:param side: 'a' or 'b'
"""
assert side in 'ab', f"Invalid side designation: {side}"
device = self.cleaned_data.get(f'side_{side}_device')
content_type = self.cleaned_data.get(f'side_{side}_type')
name = self.cleaned_data.get(f'side_{side}_name')
if not device or not content_type or not name:
return None
model = content_type.model_class()
try:
termination_object = model.objects.get(device=device, name=name)
if termination_object.cable is not None:
raise forms.ValidationError(f"Side {side.upper()}: {device} {termination_object} is already connected")
except ObjectDoesNotExist:
raise forms.ValidationError(f"{side.upper()} side termination not found: {device} {name}")
setattr(self.instance, f'termination_{side}', termination_object)
return termination_object
def clean_side_a_name(self):
return self._clean_side('a')
def clean_side_b_name(self):
return self._clean_side('b')
def clean_length_unit(self):
# Avoid trying to save as NULL
length_unit = self.cleaned_data.get('length_unit', None)
return length_unit if length_unit is not None else ''
class VirtualChassisCSVForm(CustomFieldModelCSVForm):
master = CSVModelChoiceField(
queryset=Device.objects.all(),
to_field_name='name',
required=False,
help_text='Master device'
)
class Meta:
model = VirtualChassis
fields = ('name', 'domain', 'master')
class PowerPanelCSVForm(CustomFieldModelCSVForm):
site = CSVModelChoiceField(
queryset=Site.objects.all(),
to_field_name='name',
help_text='Name of parent site'
)
location = CSVModelChoiceField(
queryset=Location.objects.all(),
required=False,
to_field_name='name'
)
class Meta:
model = PowerPanel
fields = ('site', 'location', 'name')
def __init__(self, data=None, *args, **kwargs):
super().__init__(data, *args, **kwargs)
if data:
# Limit group queryset by assigned site
params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
class PowerFeedCSVForm(CustomFieldModelCSVForm):
site = CSVModelChoiceField(
queryset=Site.objects.all(),
to_field_name='name',
help_text='Assigned site'
)
power_panel = CSVModelChoiceField(
queryset=PowerPanel.objects.all(),
to_field_name='name',
help_text='Upstream power panel'
)
location = CSVModelChoiceField(
queryset=Location.objects.all(),
to_field_name='name',
required=False,
help_text="Rack's location (if any)"
)
rack = CSVModelChoiceField(
queryset=Rack.objects.all(),
to_field_name='name',
required=False,
help_text='Rack'
)
status = CSVChoiceField(
choices=PowerFeedStatusChoices,
help_text='Operational status'
)
type = CSVChoiceField(
choices=PowerFeedTypeChoices,
help_text='Primary or redundant'
)
supply = CSVChoiceField(
choices=PowerFeedSupplyChoices,
help_text='Supply type (AC/DC)'
)
phase = CSVChoiceField(
choices=PowerFeedPhaseChoices,
help_text='Single or three-phase'
)
class Meta:
model = PowerFeed
fields = (
'site', 'power_panel', 'location', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', 'phase',
'voltage', 'amperage', 'max_utilization', 'comments',
)
def __init__(self, data=None, *args, **kwargs):
super().__init__(data, *args, **kwargs)
if data:
# Limit power_panel queryset by site
params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
self.fields['power_panel'].queryset = self.fields['power_panel'].queryset.filter(**params)
# Limit location queryset by site
params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
# Limit rack queryset by site and group
params = {
f"site__{self.fields['site'].to_field_name}": data.get('site'),
f"location__{self.fields['location'].to_field_name}": data.get('location'),
}
self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)

View File

@@ -0,0 +1,49 @@
from django import forms
from dcim.choices import *
from dcim.constants import *
__all__ = (
'InterfaceCommonForm',
)
class InterfaceCommonForm(forms.Form):
mac_address = forms.CharField(
empty_value=None,
required=False,
label='MAC address'
)
mtu = forms.IntegerField(
required=False,
min_value=INTERFACE_MTU_MIN,
max_value=INTERFACE_MTU_MAX,
label='MTU'
)
def clean(self):
super().clean()
parent_field = 'device' if 'device' in self.cleaned_data else 'virtual_machine'
tagged_vlans = self.cleaned_data.get('tagged_vlans')
# Untagged interfaces cannot be assigned tagged VLANs
if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and tagged_vlans:
raise forms.ValidationError({
'mode': "An access interface cannot have tagged VLANs assigned."
})
# Remove all tagged VLAN assignments from "tagged all" interfaces
elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED_ALL:
self.cleaned_data['tagged_vlans'] = []
# Validate tagged VLANs; must be a global VLAN or in the same site
elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED and tagged_vlans:
valid_sites = [None, self.cleaned_data[parent_field].site]
invalid_vlans = [str(v) for v in tagged_vlans if v.site not in valid_sites]
if invalid_vlans:
raise forms.ValidationError({
'tagged_vlans': f"The tagged VLANs ({', '.join(invalid_vlans)}) must belong to the same site as "
f"the interface's parent device/VM, or they must be global"
})

View File

@@ -0,0 +1,289 @@
from circuits.models import Circuit, CircuitTermination, Provider
from dcim.models import *
from extras.forms import CustomFieldModelForm
from extras.models import Tag
from utilities.forms import BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect
__all__ = (
'ConnectCableToCircuitTerminationForm',
'ConnectCableToConsolePortForm',
'ConnectCableToConsoleServerPortForm',
'ConnectCableToFrontPortForm',
'ConnectCableToInterfaceForm',
'ConnectCableToPowerFeedForm',
'ConnectCableToPowerPortForm',
'ConnectCableToPowerOutletForm',
'ConnectCableToRearPortForm',
)
class ConnectCableToDeviceForm(BootstrapMixin, CustomFieldModelForm):
"""
Base form for connecting a Cable to a Device component
"""
termination_b_region = DynamicModelChoiceField(
queryset=Region.objects.all(),
label='Region',
required=False
)
termination_b_site_group = DynamicModelChoiceField(
queryset=SiteGroup.objects.all(),
label='Site group',
required=False
)
termination_b_site = DynamicModelChoiceField(
queryset=Site.objects.all(),
label='Site',
required=False,
query_params={
'region_id': '$termination_b_region',
'group_id': '$termination_b_site_group',
}
)
termination_b_location = DynamicModelChoiceField(
queryset=Location.objects.all(),
label='Location',
required=False,
null_option='None',
query_params={
'site_id': '$termination_b_site'
}
)
termination_b_rack = DynamicModelChoiceField(
queryset=Rack.objects.all(),
label='Rack',
required=False,
null_option='None',
query_params={
'site_id': '$termination_b_site',
'location_id': '$termination_b_location',
}
)
termination_b_device = DynamicModelChoiceField(
queryset=Device.objects.all(),
label='Device',
required=False,
query_params={
'site_id': '$termination_b_site',
'location_id': '$termination_b_location',
'rack_id': '$termination_b_rack',
}
)
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta:
model = Cable
fields = [
'termination_b_region', 'termination_b_site', 'termination_b_rack', 'termination_b_device',
'termination_b_id', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags',
]
widgets = {
'status': StaticSelect,
'type': StaticSelect,
'length_unit': StaticSelect,
}
def clean_termination_b_id(self):
# Return the PK rather than the object
return getattr(self.cleaned_data['termination_b_id'], 'pk', None)
class ConnectCableToConsolePortForm(ConnectCableToDeviceForm):
termination_b_id = DynamicModelChoiceField(
queryset=ConsolePort.objects.all(),
label='Name',
disabled_indicator='_occupied',
query_params={
'device_id': '$termination_b_device'
}
)
class ConnectCableToConsoleServerPortForm(ConnectCableToDeviceForm):
termination_b_id = DynamicModelChoiceField(
queryset=ConsoleServerPort.objects.all(),
label='Name',
disabled_indicator='_occupied',
query_params={
'device_id': '$termination_b_device'
}
)
class ConnectCableToPowerPortForm(ConnectCableToDeviceForm):
termination_b_id = DynamicModelChoiceField(
queryset=PowerPort.objects.all(),
label='Name',
disabled_indicator='_occupied',
query_params={
'device_id': '$termination_b_device'
}
)
class ConnectCableToPowerOutletForm(ConnectCableToDeviceForm):
termination_b_id = DynamicModelChoiceField(
queryset=PowerOutlet.objects.all(),
label='Name',
disabled_indicator='_occupied',
query_params={
'device_id': '$termination_b_device'
}
)
class ConnectCableToInterfaceForm(ConnectCableToDeviceForm):
termination_b_id = DynamicModelChoiceField(
queryset=Interface.objects.all(),
label='Name',
disabled_indicator='_occupied',
query_params={
'device_id': '$termination_b_device',
'kind': 'physical',
}
)
class ConnectCableToFrontPortForm(ConnectCableToDeviceForm):
termination_b_id = DynamicModelChoiceField(
queryset=FrontPort.objects.all(),
label='Name',
disabled_indicator='_occupied',
query_params={
'device_id': '$termination_b_device'
}
)
class ConnectCableToRearPortForm(ConnectCableToDeviceForm):
termination_b_id = DynamicModelChoiceField(
queryset=RearPort.objects.all(),
label='Name',
disabled_indicator='_occupied',
query_params={
'device_id': '$termination_b_device'
}
)
class ConnectCableToCircuitTerminationForm(BootstrapMixin, CustomFieldModelForm):
termination_b_provider = DynamicModelChoiceField(
queryset=Provider.objects.all(),
label='Provider',
required=False
)
termination_b_region = DynamicModelChoiceField(
queryset=Region.objects.all(),
label='Region',
required=False
)
termination_b_site_group = DynamicModelChoiceField(
queryset=SiteGroup.objects.all(),
label='Site group',
required=False
)
termination_b_site = DynamicModelChoiceField(
queryset=Site.objects.all(),
label='Site',
required=False,
query_params={
'region_id': '$termination_b_region',
'group_id': '$termination_b_site_group',
}
)
termination_b_circuit = DynamicModelChoiceField(
queryset=Circuit.objects.all(),
label='Circuit',
query_params={
'provider_id': '$termination_b_provider',
'site_id': '$termination_b_site',
}
)
termination_b_id = DynamicModelChoiceField(
queryset=CircuitTermination.objects.all(),
label='Side',
disabled_indicator='_occupied',
query_params={
'circuit_id': '$termination_b_circuit'
}
)
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta:
model = Cable
fields = [
'termination_b_provider', 'termination_b_region', 'termination_b_site', 'termination_b_circuit',
'termination_b_id', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags',
]
def clean_termination_b_id(self):
# Return the PK rather than the object
return getattr(self.cleaned_data['termination_b_id'], 'pk', None)
class ConnectCableToPowerFeedForm(BootstrapMixin, CustomFieldModelForm):
termination_b_region = DynamicModelChoiceField(
queryset=Region.objects.all(),
label='Region',
required=False
)
termination_b_site_group = DynamicModelChoiceField(
queryset=SiteGroup.objects.all(),
label='Site group',
required=False
)
termination_b_site = DynamicModelChoiceField(
queryset=Site.objects.all(),
label='Site',
required=False,
query_params={
'region_id': '$termination_b_region',
'group_id': '$termination_b_site_group',
}
)
termination_b_location = DynamicModelChoiceField(
queryset=Location.objects.all(),
label='Location',
required=False,
query_params={
'site_id': '$termination_b_site'
}
)
termination_b_powerpanel = DynamicModelChoiceField(
queryset=PowerPanel.objects.all(),
label='Power Panel',
required=False,
query_params={
'site_id': '$termination_b_site',
'location_id': '$termination_b_location',
}
)
termination_b_id = DynamicModelChoiceField(
queryset=PowerFeed.objects.all(),
label='Name',
disabled_indicator='_occupied',
query_params={
'power_panel_id': '$termination_b_powerpanel'
}
)
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta:
model = Cable
fields = [
'termination_b_location', 'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'label',
'color', 'length', 'length_unit', 'tags',
]
def clean_termination_b_id(self):
# Return the PK rather than the object
return getattr(self.cleaned_data['termination_b_id'], 'pk', None)

View File

@@ -0,0 +1,25 @@
from django import forms
from netaddr import EUI
from netaddr.core import AddrFormatError
__all__ = (
'MACAddressField',
)
class MACAddressField(forms.Field):
widget = forms.CharField
default_error_messages = {
'invalid': 'MAC address must be in EUI-48 format',
}
def to_python(self, value):
value = super().to_python(value)
# Validate MAC address format
try:
value = EUI(value.strip())
except AddrFormatError:
raise forms.ValidationError(self.error_messages['invalid'], code='invalid')
return value

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
from django import forms
__all__ = (
'BaseVCMemberFormSet',
)
class BaseVCMemberFormSet(forms.BaseModelFormSet):
def clean(self):
super().clean()
# Check for duplicate VC position values
vc_position_list = []
for form in self.forms:
vc_position = form.cleaned_data.get('vc_position')
if vc_position:
if vc_position in vc_position_list:
error_msg = f"A virtual chassis member already exists in position {vc_position}."
form.add_error('vc_position', error_msg)
vc_position_list.append(vc_position)

1234
netbox/dcim/forms/models.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,614 @@
from django import forms
from dcim.choices import *
from dcim.constants import *
from dcim.models import *
from extras.forms import CustomFieldModelForm, CustomFieldsMixin
from extras.models import Tag
from ipam.models import VLAN
from utilities.forms import (
add_blank_choice, BootstrapMixin, ColorField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
ExpandableNameField, StaticSelect,
)
from .common import InterfaceCommonForm
__all__ = (
'ConsolePortCreateForm',
'ConsolePortTemplateCreateForm',
'ConsoleServerPortCreateForm',
'ConsoleServerPortTemplateCreateForm',
'DeviceBayCreateForm',
'DeviceBayTemplateCreateForm',
'FrontPortCreateForm',
'FrontPortTemplateCreateForm',
'InterfaceCreateForm',
'InterfaceTemplateCreateForm',
'InventoryItemCreateForm',
'PowerOutletCreateForm',
'PowerOutletTemplateCreateForm',
'PowerPortCreateForm',
'PowerPortTemplateCreateForm',
'RearPortCreateForm',
'RearPortTemplateCreateForm',
'VirtualChassisCreateForm',
)
class ComponentForm(forms.Form):
"""
Subclass this form when facilitating the creation of one or more device component or component templates based on
a name pattern.
"""
name_pattern = ExpandableNameField(
label='Name'
)
label_pattern = ExpandableNameField(
label='Label',
required=False,
help_text='Alphanumeric ranges are supported. (Must match the number of names being created.)'
)
def clean(self):
super().clean()
# Validate that the number of components being created from both the name_pattern and label_pattern are equal
if self.cleaned_data['label_pattern']:
name_pattern_count = len(self.cleaned_data['name_pattern'])
label_pattern_count = len(self.cleaned_data['label_pattern'])
if name_pattern_count != label_pattern_count:
raise forms.ValidationError({
'label_pattern': f'The provided name pattern will create {name_pattern_count} components, however '
f'{label_pattern_count} labels will be generated. These counts must match.'
}, code='label_pattern_mismatch')
class VirtualChassisCreateForm(BootstrapMixin, CustomFieldModelForm):
region = DynamicModelChoiceField(
queryset=Region.objects.all(),
required=False,
initial_params={
'sites': '$site'
}
)
site_group = DynamicModelChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
initial_params={
'sites': '$site'
}
)
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False,
query_params={
'region_id': '$region',
'group_id': '$site_group',
}
)
rack = DynamicModelChoiceField(
queryset=Rack.objects.all(),
required=False,
null_option='None',
query_params={
'site_id': '$site'
}
)
members = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
required=False,
query_params={
'site_id': '$site',
'rack_id': '$rack',
}
)
initial_position = forms.IntegerField(
initial=1,
required=False,
help_text='Position of the first member device. Increases by one for each additional member.'
)
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta:
model = VirtualChassis
fields = [
'name', 'domain', 'region', 'site_group', 'site', 'rack', 'members', 'initial_position', 'tags',
]
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
for i, member in enumerate(self.cleaned_data['members'], start=initial_position):
member.virtual_chassis = instance
member.vc_position = i
member.save()
return instance
#
# Component templates
#
class ComponentTemplateCreateForm(BootstrapMixin, ComponentForm):
"""
Base form for the creation of device component templates (subclassed from ComponentTemplateModel).
"""
manufacturer = DynamicModelChoiceField(
queryset=Manufacturer.objects.all(),
required=False,
initial_params={
'device_types': 'device_type'
}
)
device_type = DynamicModelChoiceField(
queryset=DeviceType.objects.all(),
query_params={
'manufacturer_id': '$manufacturer'
}
)
description = forms.CharField(
required=False
)
class ConsolePortTemplateCreateForm(ComponentTemplateCreateForm):
type = forms.ChoiceField(
choices=add_blank_choice(ConsolePortTypeChoices),
widget=StaticSelect()
)
field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'description')
class ConsoleServerPortTemplateCreateForm(ComponentTemplateCreateForm):
type = forms.ChoiceField(
choices=add_blank_choice(ConsolePortTypeChoices),
widget=StaticSelect()
)
field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'description')
class PowerPortTemplateCreateForm(ComponentTemplateCreateForm):
type = forms.ChoiceField(
choices=add_blank_choice(PowerPortTypeChoices),
required=False
)
maximum_draw = forms.IntegerField(
min_value=1,
required=False,
help_text="Maximum power draw (watts)"
)
allocated_draw = forms.IntegerField(
min_value=1,
required=False,
help_text="Allocated power draw (watts)"
)
field_order = (
'manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'maximum_draw', 'allocated_draw',
'description',
)
class PowerOutletTemplateCreateForm(ComponentTemplateCreateForm):
type = forms.ChoiceField(
choices=add_blank_choice(PowerOutletTypeChoices),
required=False
)
power_port = forms.ModelChoiceField(
queryset=PowerPortTemplate.objects.all(),
required=False
)
feed_leg = forms.ChoiceField(
choices=add_blank_choice(PowerOutletFeedLegChoices),
required=False,
widget=StaticSelect()
)
field_order = (
'manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'power_port', 'feed_leg',
'description',
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Limit power_port choices to current DeviceType
device_type = DeviceType.objects.get(
pk=self.initial.get('device_type') or self.data.get('device_type')
)
self.fields['power_port'].queryset = PowerPortTemplate.objects.filter(
device_type=device_type
)
class InterfaceTemplateCreateForm(ComponentTemplateCreateForm):
type = forms.ChoiceField(
choices=InterfaceTypeChoices,
widget=StaticSelect()
)
mgmt_only = forms.BooleanField(
required=False,
label='Management only'
)
field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'mgmt_only', 'description')
class FrontPortTemplateCreateForm(ComponentTemplateCreateForm):
type = forms.ChoiceField(
choices=PortTypeChoices,
widget=StaticSelect()
)
color = ColorField(
required=False
)
rear_port_set = forms.MultipleChoiceField(
choices=[],
label='Rear ports',
help_text='Select one rear port assignment for each front port being created.',
)
field_order = (
'manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'color', 'rear_port_set', 'description',
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
device_type = DeviceType.objects.get(
pk=self.initial.get('device_type') or self.data.get('device_type')
)
# Determine which rear port positions are occupied. These will be excluded from the list of available mappings.
occupied_port_positions = [
(front_port.rear_port_id, front_port.rear_port_position)
for front_port in device_type.frontporttemplates.all()
]
# Populate rear port choices
choices = []
rear_ports = RearPortTemplate.objects.filter(device_type=device_type)
for rear_port in rear_ports:
for i in range(1, rear_port.positions + 1):
if (rear_port.pk, i) not in occupied_port_positions:
choices.append(
('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i))
)
self.fields['rear_port_set'].choices = choices
def clean(self):
super().clean()
# Validate that the number of ports being created equals the number of selected (rear port, position) tuples
front_port_count = len(self.cleaned_data['name_pattern'])
rear_port_count = len(self.cleaned_data['rear_port_set'])
if front_port_count != rear_port_count:
raise forms.ValidationError({
'rear_port_set': 'The provided name pattern will create {} ports, however {} rear port assignments '
'were selected. These counts must match.'.format(front_port_count, rear_port_count)
})
def get_iterative_data(self, iteration):
# Assign rear port and position from selected set
rear_port, position = self.cleaned_data['rear_port_set'][iteration].split(':')
return {
'rear_port': int(rear_port),
'rear_port_position': int(position),
}
class RearPortTemplateCreateForm(ComponentTemplateCreateForm):
type = forms.ChoiceField(
choices=PortTypeChoices,
widget=StaticSelect(),
)
color = ColorField(
required=False
)
positions = forms.IntegerField(
min_value=REARPORT_POSITIONS_MIN,
max_value=REARPORT_POSITIONS_MAX,
initial=1,
help_text='The number of front ports which may be mapped to each rear port'
)
field_order = (
'manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'color', 'positions', 'description',
)
class DeviceBayTemplateCreateForm(ComponentTemplateCreateForm):
field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'description')
#
# Device components
#
class ComponentCreateForm(BootstrapMixin, CustomFieldsMixin, ComponentForm):
"""
Base form for the creation of device components (models subclassed from ComponentModel).
"""
device = DynamicModelChoiceField(
queryset=Device.objects.all()
)
description = forms.CharField(
max_length=200,
required=False
)
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class ConsolePortCreateForm(ComponentCreateForm):
model = ConsolePort
type = forms.ChoiceField(
choices=add_blank_choice(ConsolePortTypeChoices),
required=False,
widget=StaticSelect()
)
speed = forms.ChoiceField(
choices=add_blank_choice(ConsolePortSpeedChoices),
required=False,
widget=StaticSelect()
)
field_order = ('device', 'name_pattern', 'label_pattern', 'type', 'speed', 'mark_connected', 'description', 'tags')
class ConsoleServerPortCreateForm(ComponentCreateForm):
model = ConsoleServerPort
type = forms.ChoiceField(
choices=add_blank_choice(ConsolePortTypeChoices),
required=False,
widget=StaticSelect()
)
speed = forms.ChoiceField(
choices=add_blank_choice(ConsolePortSpeedChoices),
required=False,
widget=StaticSelect()
)
field_order = ('device', 'name_pattern', 'label_pattern', 'type', 'speed', 'mark_connected', 'description', 'tags')
class PowerPortCreateForm(ComponentCreateForm):
model = PowerPort
type = forms.ChoiceField(
choices=add_blank_choice(PowerPortTypeChoices),
required=False,
widget=StaticSelect()
)
maximum_draw = forms.IntegerField(
min_value=1,
required=False,
help_text="Maximum draw in watts"
)
allocated_draw = forms.IntegerField(
min_value=1,
required=False,
help_text="Allocated draw in watts"
)
field_order = (
'device', 'name_pattern', 'label_pattern', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected',
'description', 'tags',
)
class PowerOutletCreateForm(ComponentCreateForm):
model = PowerOutlet
type = forms.ChoiceField(
choices=add_blank_choice(PowerOutletTypeChoices),
required=False,
widget=StaticSelect()
)
power_port = forms.ModelChoiceField(
queryset=PowerPort.objects.all(),
required=False
)
feed_leg = forms.ChoiceField(
choices=add_blank_choice(PowerOutletFeedLegChoices),
required=False
)
field_order = (
'device', 'name_pattern', 'label_pattern', 'type', 'power_port', 'feed_leg', 'mark_connected', 'description',
'tags',
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Limit power_port queryset to PowerPorts which belong to the parent Device
device = Device.objects.get(
pk=self.initial.get('device') or self.data.get('device')
)
self.fields['power_port'].queryset = PowerPort.objects.filter(device=device)
class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm):
model = Interface
type = forms.ChoiceField(
choices=InterfaceTypeChoices,
widget=StaticSelect(),
)
enabled = forms.BooleanField(
required=False,
initial=True
)
parent = DynamicModelChoiceField(
queryset=Interface.objects.all(),
required=False,
query_params={
'device_id': '$device',
}
)
lag = DynamicModelChoiceField(
queryset=Interface.objects.all(),
required=False,
query_params={
'device_id': '$device',
'type': 'lag',
}
)
mac_address = forms.CharField(
required=False,
label='MAC Address'
)
mgmt_only = forms.BooleanField(
required=False,
label='Management only',
help_text='This interface is used only for out-of-band management'
)
mode = forms.ChoiceField(
choices=add_blank_choice(InterfaceModeChoices),
required=False,
widget=StaticSelect(),
)
untagged_vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(),
required=False
)
tagged_vlans = DynamicModelMultipleChoiceField(
queryset=VLAN.objects.all(),
required=False
)
field_order = (
'device', 'name_pattern', 'label_pattern', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address',
'description', 'mgmt_only', 'mark_connected', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags'
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Limit VLAN choices by device
device_id = self.initial.get('device') or self.data.get('device')
self.fields['untagged_vlan'].widget.add_query_param('available_on_device', device_id)
self.fields['tagged_vlans'].widget.add_query_param('available_on_device', device_id)
class FrontPortCreateForm(ComponentCreateForm):
model = FrontPort
type = forms.ChoiceField(
choices=PortTypeChoices,
widget=StaticSelect(),
)
color = ColorField(
required=False
)
rear_port_set = forms.MultipleChoiceField(
choices=[],
label='Rear ports',
help_text='Select one rear port assignment for each front port being created.',
)
field_order = (
'device', 'name_pattern', 'label_pattern', 'type', 'color', 'rear_port_set', 'mark_connected', 'description',
'tags',
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
device = Device.objects.get(
pk=self.initial.get('device') or self.data.get('device')
)
# Determine which rear port positions are occupied. These will be excluded from the list of available
# mappings.
occupied_port_positions = [
(front_port.rear_port_id, front_port.rear_port_position)
for front_port in device.frontports.all()
]
# Populate rear port choices
choices = []
rear_ports = RearPort.objects.filter(device=device)
for rear_port in rear_ports:
for i in range(1, rear_port.positions + 1):
if (rear_port.pk, i) not in occupied_port_positions:
choices.append(
('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i))
)
self.fields['rear_port_set'].choices = choices
def clean(self):
super().clean()
# Validate that the number of ports being created equals the number of selected (rear port, position) tuples
front_port_count = len(self.cleaned_data['name_pattern'])
rear_port_count = len(self.cleaned_data['rear_port_set'])
if front_port_count != rear_port_count:
raise forms.ValidationError({
'rear_port_set': 'The provided name pattern will create {} ports, however {} rear port assignments '
'were selected. These counts must match.'.format(front_port_count, rear_port_count)
})
def get_iterative_data(self, iteration):
# Assign rear port and position from selected set
rear_port, position = self.cleaned_data['rear_port_set'][iteration].split(':')
return {
'rear_port': int(rear_port),
'rear_port_position': int(position),
}
class RearPortCreateForm(ComponentCreateForm):
model = RearPort
type = forms.ChoiceField(
choices=PortTypeChoices,
widget=StaticSelect(),
)
color = ColorField(
required=False
)
positions = forms.IntegerField(
min_value=REARPORT_POSITIONS_MIN,
max_value=REARPORT_POSITIONS_MAX,
initial=1,
help_text='The number of front ports which may be mapped to each rear port'
)
field_order = (
'device', 'name_pattern', 'label_pattern', 'type', 'color', 'positions', 'mark_connected', 'description',
'tags',
)
class DeviceBayCreateForm(ComponentCreateForm):
model = DeviceBay
field_order = ('device', 'name_pattern', 'label_pattern', 'description', 'tags')
class InventoryItemCreateForm(ComponentCreateForm):
model = InventoryItem
manufacturer = DynamicModelChoiceField(
queryset=Manufacturer.objects.all(),
required=False
)
parent = DynamicModelChoiceField(
queryset=InventoryItem.objects.all(),
required=False,
query_params={
'device_id': '$device'
}
)
part_id = forms.CharField(
max_length=50,
required=False,
label='Part ID'
)
serial = forms.CharField(
max_length=50,
required=False,
)
asset_tag = forms.CharField(
max_length=50,
required=False,
)
field_order = (
'device', 'parent', 'name_pattern', 'label_pattern', 'manufacturer', 'part_id', 'serial', 'asset_tag',
'description', 'tags',
)

View File

@@ -0,0 +1,148 @@
from django import forms
from dcim.choices import InterfaceTypeChoices, PortTypeChoices
from dcim.models import *
from utilities.forms import BootstrapMixin
__all__ = (
'ConsolePortTemplateImportForm',
'ConsoleServerPortTemplateImportForm',
'DeviceBayTemplateImportForm',
'DeviceTypeImportForm',
'FrontPortTemplateImportForm',
'InterfaceTemplateImportForm',
'PowerOutletTemplateImportForm',
'PowerPortTemplateImportForm',
'RearPortTemplateImportForm',
)
class DeviceTypeImportForm(BootstrapMixin, forms.ModelForm):
manufacturer = forms.ModelChoiceField(
queryset=Manufacturer.objects.all(),
to_field_name='name'
)
class Meta:
model = DeviceType
fields = [
'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
'comments',
]
#
# Component template import forms
#
class ComponentTemplateImportForm(BootstrapMixin, forms.ModelForm):
def __init__(self, device_type, data=None, *args, **kwargs):
# Must pass the parent DeviceType on form initialization
data.update({
'device_type': device_type.pk,
})
super().__init__(data, *args, **kwargs)
def clean_device_type(self):
data = self.cleaned_data['device_type']
# Limit fields referencing other components to the parent DeviceType
for field_name, field in self.fields.items():
if isinstance(field, forms.ModelChoiceField) and field_name != 'device_type':
field.queryset = field.queryset.filter(device_type=data)
return data
class ConsolePortTemplateImportForm(ComponentTemplateImportForm):
class Meta:
model = ConsolePortTemplate
fields = [
'device_type', 'name', 'label', 'type', 'description',
]
class ConsoleServerPortTemplateImportForm(ComponentTemplateImportForm):
class Meta:
model = ConsoleServerPortTemplate
fields = [
'device_type', 'name', 'label', 'type', 'description',
]
class PowerPortTemplateImportForm(ComponentTemplateImportForm):
class Meta:
model = PowerPortTemplate
fields = [
'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description',
]
class PowerOutletTemplateImportForm(ComponentTemplateImportForm):
power_port = forms.ModelChoiceField(
queryset=PowerPortTemplate.objects.all(),
to_field_name='name',
required=False
)
class Meta:
model = PowerOutletTemplate
fields = [
'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description',
]
class InterfaceTemplateImportForm(ComponentTemplateImportForm):
type = forms.ChoiceField(
choices=InterfaceTypeChoices.CHOICES
)
class Meta:
model = InterfaceTemplate
fields = [
'device_type', 'name', 'label', 'type', 'mgmt_only', 'description',
]
class FrontPortTemplateImportForm(ComponentTemplateImportForm):
type = forms.ChoiceField(
choices=PortTypeChoices.CHOICES
)
rear_port = forms.ModelChoiceField(
queryset=RearPortTemplate.objects.all(),
to_field_name='name'
)
class Meta:
model = FrontPortTemplate
fields = [
'device_type', 'name', 'type', 'rear_port', 'rear_port_position', 'label', 'description',
]
class RearPortTemplateImportForm(ComponentTemplateImportForm):
type = forms.ChoiceField(
choices=PortTypeChoices.CHOICES
)
class Meta:
model = RearPortTemplate
fields = [
'device_type', 'name', 'type', 'positions', 'label', 'description',
]
class DeviceBayTemplateImportForm(ComponentTemplateImportForm):
class Meta:
model = DeviceBayTemplate
fields = [
'device_type', 'name', 'label', 'description',
]

View File

@@ -185,11 +185,10 @@ class PathEndpoint(models.Model):
# Construct the complete path
path = [self, *self._path.get_path()]
if self._path.destination:
path.append(self._path.destination)
while len(path) % 3:
# Pad to ensure we have complete three-tuples (e.g. for paths that end at a RearPort)
path.insert(-1, None)
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)
# Return the path as a list of three-tuples (A termination, cable, B termination)
return list(zip(*[iter(path)] * 3))

View File

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

View File

@@ -112,6 +112,9 @@ class RackElevationSVG:
)
image.fit(scale='slice')
link.add(image)
link.add(drawing.text(str(name), insert=text, stroke='black',
stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label'))
link.add(drawing.text(str(name), insert=text, fill='white', class_='device-image-label'))
def _draw_device_rear(self, drawing, device, start, end, text):
rect = drawing.rect(start, end, class_="slot blocked")
@@ -129,17 +132,24 @@ class RackElevationSVG:
)
image.fit(scale='slice')
drawing.add(image)
drawing.add(drawing.text(str(device), insert=text, stroke='black',
stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label'))
drawing.add(drawing.text(str(device), insert=text, fill='white', class_='device-image-label'))
@staticmethod
def _draw_empty(drawing, rack, start, end, text, id_, face_id, class_, reservation):
link_url = '{}?{}'.format(
reverse('dcim:device_add'),
urlencode({
'site': rack.site.pk,
'location': rack.location.pk if rack.location else '',
'rack': rack.pk,
'face': face_id,
'position': id_
})
)
link = drawing.add(
drawing.a(
href='{}?{}'.format(
reverse('dcim:device_add'),
urlencode({'rack': rack.pk, 'site': rack.site.pk, 'face': face_id, 'position': id_})
),
target='_top'
)
drawing.a(href=link_url, target='_top')
)
if reservation:
link.set_desc('{}{} · {}'.format(
@@ -482,7 +492,7 @@ class CableTraceSVG:
)
parent_objects.append(parent_object)
else:
elif far_end:
# Attachment
attachment = self._draw_attachment()

View File

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

View File

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

View File

@@ -9,7 +9,7 @@ from dcim.models import (
from tenancy.tables import TenantColumn
from utilities.tables import (
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn,
TagColumn, ToggleColumn,
MarkdownColumn, TagColumn, TemplateColumn, ToggleColumn,
)
from .template_code import (
CABLETERMINATION, CONSOLEPORT_BUTTONS, CONSOLESERVERPORT_BUTTONS, DEVICE_LINK, DEVICEBAY_BUTTONS, DEVICEBAY_STATUS,
@@ -18,6 +18,7 @@ from .template_code import (
)
__all__ = (
'BaseInterfaceTable',
'ConsolePortTable',
'ConsoleServerPortTable',
'DeviceBayTable',
@@ -87,7 +88,7 @@ class DeviceRoleTable(BaseTable):
class Meta(BaseTable.Meta):
model = DeviceRole
fields = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'slug', 'actions')
fields = ('pk', 'id', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'slug', 'actions')
default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'actions')
@@ -115,7 +116,7 @@ class PlatformTable(BaseTable):
class Meta(BaseTable.Meta):
model = Platform
fields = (
'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'napalm_args',
'pk', 'id', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'napalm_args',
'description', 'actions',
)
default_columns = (
@@ -187,6 +188,7 @@ class DeviceTable(BaseTable):
vc_priority = tables.Column(
verbose_name='VC Priority'
)
comments = MarkdownColumn()
tags = TagColumn(
url_name='dcim:device_list'
)
@@ -194,9 +196,9 @@ class DeviceTable(BaseTable):
class Meta(BaseTable.Meta):
model = Device
fields = (
'pk', 'name', 'status', 'tenant', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial',
'pk', 'id', 'name', 'status', 'tenant', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial',
'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'primary_ip', 'primary_ip4', 'primary_ip6',
'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'tags',
'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags',
)
default_columns = (
'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type',
@@ -225,7 +227,7 @@ class DeviceImportTable(BaseTable):
class Meta(BaseTable.Meta):
model = Device
fields = ('name', 'status', 'tenant', 'site', 'rack', 'position', 'device_role', 'device_type')
fields = ('id', 'name', 'status', 'tenant', 'site', 'rack', 'position', 'device_role', 'device_type')
empty_text = False
@@ -256,7 +258,7 @@ class CableTerminationTable(BaseTable):
orderable=False,
verbose_name='Cable Color'
)
cable_peer = tables.TemplateColumn(
cable_peer = TemplateColumn(
accessor='_cable_peer',
template_code=CABLETERMINATION,
orderable=False,
@@ -266,7 +268,7 @@ class CableTerminationTable(BaseTable):
class PathEndpointTable(CableTerminationTable):
connection = tables.TemplateColumn(
connection = TemplateColumn(
accessor='_path.last_node',
template_code=CABLETERMINATION,
verbose_name='Connection',
@@ -288,7 +290,7 @@ class ConsolePortTable(DeviceComponentTable, PathEndpointTable):
class Meta(DeviceComponentTable.Meta):
model = ConsolePort
fields = (
'pk', 'name', 'device', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color',
'pk', 'id', 'name', 'device', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color',
'cable_peer', 'connection', 'tags',
)
default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description')
@@ -309,7 +311,7 @@ class DeviceConsolePortTable(ConsolePortTable):
class Meta(DeviceComponentTable.Meta):
model = ConsolePort
fields = (
'pk', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color',
'pk', 'id', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color',
'cable_peer', 'connection', 'tags', 'actions'
)
default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions')
@@ -332,7 +334,7 @@ class ConsoleServerPortTable(DeviceComponentTable, PathEndpointTable):
class Meta(DeviceComponentTable.Meta):
model = ConsoleServerPort
fields = (
'pk', 'name', 'device', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color',
'pk', 'id', 'name', 'device', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color',
'cable_peer', 'connection', 'tags',
)
default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description')
@@ -354,7 +356,7 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable):
class Meta(DeviceComponentTable.Meta):
model = ConsoleServerPort
fields = (
'pk', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color',
'pk', 'id', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color',
'cable_peer', 'connection', 'tags', 'actions',
)
default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions')
@@ -377,7 +379,7 @@ class PowerPortTable(DeviceComponentTable, PathEndpointTable):
class Meta(DeviceComponentTable.Meta):
model = PowerPort
fields = (
'pk', 'name', 'device', 'label', 'type', 'description', 'mark_connected', 'maximum_draw', 'allocated_draw',
'pk', 'id', 'name', 'device', 'label', 'type', 'description', 'mark_connected', 'maximum_draw', 'allocated_draw',
'cable', 'cable_color', 'cable_peer', 'connection', 'tags',
)
default_columns = ('pk', 'name', 'device', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description')
@@ -399,7 +401,7 @@ class DevicePowerPortTable(PowerPortTable):
class Meta(DeviceComponentTable.Meta):
model = PowerPort
fields = (
'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'mark_connected', 'cable',
'pk', 'id', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'mark_connected', 'cable',
'cable_color', 'cable_peer', 'connection', 'tags', 'actions',
)
default_columns = (
@@ -428,7 +430,7 @@ class PowerOutletTable(DeviceComponentTable, PathEndpointTable):
class Meta(DeviceComponentTable.Meta):
model = PowerOutlet
fields = (
'pk', 'name', 'device', 'label', 'type', 'description', 'power_port', 'feed_leg', 'mark_connected', 'cable',
'pk', 'id', 'name', 'device', 'label', 'type', 'description', 'power_port', 'feed_leg', 'mark_connected', 'cable',
'cable_color', 'cable_peer', 'connection', 'tags',
)
default_columns = ('pk', 'name', 'device', 'label', 'type', 'power_port', 'feed_leg', 'description')
@@ -449,7 +451,7 @@ class DevicePowerOutletTable(PowerOutletTable):
class Meta(DeviceComponentTable.Meta):
model = PowerOutlet
fields = (
'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'mark_connected', 'cable',
'pk', 'id', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'mark_connected', 'cable',
'cable_color', 'cable_peer', 'connection', 'tags', 'actions',
)
default_columns = (
@@ -468,7 +470,7 @@ class BaseInterfaceTable(BaseTable):
verbose_name='IP Addresses'
)
untagged_vlan = tables.Column(linkify=True)
tagged_vlans = tables.TemplateColumn(
tagged_vlans = TemplateColumn(
template_code=INTERFACE_TAGGED_VLANS,
orderable=False,
verbose_name='Tagged VLANs'
@@ -490,7 +492,7 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable
class Meta(DeviceComponentTable.Meta):
model = Interface
fields = (
'pk', 'name', 'device', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address',
'pk', 'id', 'name', 'device', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address',
'description', 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'connection', 'tags', 'ip_addresses',
'untagged_vlan', 'tagged_vlans',
)
@@ -522,7 +524,7 @@ class DeviceInterfaceTable(InterfaceTable):
class Meta(DeviceComponentTable.Meta):
model = Interface
fields = (
'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address',
'pk', 'id', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address',
'description', 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'connection', 'tags', 'ip_addresses',
'untagged_vlan', 'tagged_vlans', 'actions',
)
@@ -559,7 +561,7 @@ class FrontPortTable(DeviceComponentTable, CableTerminationTable):
class Meta(DeviceComponentTable.Meta):
model = FrontPort
fields = (
'pk', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description',
'pk', 'id', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description',
'mark_connected', 'cable', 'cable_color', 'cable_peer', 'tags',
)
default_columns = (
@@ -583,7 +585,7 @@ class DeviceFrontPortTable(FrontPortTable):
class Meta(DeviceComponentTable.Meta):
model = FrontPort
fields = (
'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'mark_connected', 'cable',
'pk', 'id', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'mark_connected', 'cable',
'cable_color', 'cable_peer', 'tags', 'actions',
)
default_columns = (
@@ -610,7 +612,7 @@ class RearPortTable(DeviceComponentTable, CableTerminationTable):
class Meta(DeviceComponentTable.Meta):
model = RearPort
fields = (
'pk', 'name', 'device', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable',
'pk', 'id', 'name', 'device', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable',
'cable_color', 'cable_peer', 'tags',
)
default_columns = ('pk', 'name', 'device', 'label', 'type', 'color', 'description')
@@ -632,7 +634,7 @@ class DeviceRearPortTable(RearPortTable):
class Meta(DeviceComponentTable.Meta):
model = RearPort
fields = (
'pk', 'name', 'label', 'type', 'positions', 'description', 'mark_connected', 'cable', 'cable_color',
'pk', 'id', 'name', 'label', 'type', 'positions', 'description', 'mark_connected', 'cable', 'cable_color',
'cable_peer', 'tags', 'actions',
)
default_columns = (
@@ -662,7 +664,7 @@ class DeviceBayTable(DeviceComponentTable):
class Meta(DeviceComponentTable.Meta):
model = DeviceBay
fields = ('pk', 'name', 'device', 'label', 'status', 'installed_device', 'description', 'tags')
fields = ('pk', 'id', 'name', 'device', 'label', 'status', 'installed_device', 'description', 'tags')
default_columns = ('pk', 'name', 'device', 'label', 'status', 'installed_device', 'description')
@@ -682,7 +684,7 @@ class DeviceDeviceBayTable(DeviceBayTable):
class Meta(DeviceComponentTable.Meta):
model = DeviceBay
fields = (
'pk', 'name', 'label', 'status', 'installed_device', 'description', 'tags', 'actions',
'pk', 'id', 'name', 'label', 'status', 'installed_device', 'description', 'tags', 'actions',
)
default_columns = (
'pk', 'name', 'label', 'status', 'installed_device', 'description', 'actions',
@@ -708,7 +710,7 @@ class InventoryItemTable(DeviceComponentTable):
class Meta(BaseTable.Meta):
model = InventoryItem
fields = (
'pk', 'name', 'device', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description',
'pk', 'id', 'name', 'device', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description',
'discovered', 'tags',
)
default_columns = ('pk', 'name', 'device', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag')
@@ -729,7 +731,7 @@ class DeviceInventoryItemTable(InventoryItemTable):
class Meta(BaseTable.Meta):
model = InventoryItem
fields = (
'pk', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'discovered',
'pk', 'id', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'discovered',
'tags', 'actions',
)
default_columns = (
@@ -761,5 +763,5 @@ class VirtualChassisTable(BaseTable):
class Meta(BaseTable.Meta):
model = VirtualChassis
fields = ('pk', 'name', 'domain', 'master', 'member_count', 'tags')
fields = ('pk', 'id', 'name', 'domain', 'master', 'member_count', 'tags')
default_columns = ('pk', 'name', 'domain', 'master', 'member_count')

View File

@@ -5,7 +5,7 @@ from dcim.models import (
Manufacturer, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
)
from utilities.tables import (
BaseTable, BooleanColumn, ButtonsColumn, ColorColumn, LinkedCountColumn, TagColumn, ToggleColumn,
BaseTable, BooleanColumn, ButtonsColumn, ColorColumn, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn,
)
__all__ = (
@@ -46,6 +46,9 @@ class ManufacturerTable(BaseTable):
class Meta(BaseTable.Meta):
model = Manufacturer
fields = (
'pk', 'id', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'actions',
)
default_columns = (
'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'actions',
)
@@ -68,6 +71,7 @@ class DeviceTypeTable(BaseTable):
url_params={'device_type_id': 'pk'},
verbose_name='Instances'
)
comments = MarkdownColumn()
tags = TagColumn(
url_name='dcim:devicetype_list'
)
@@ -75,8 +79,8 @@ class DeviceTypeTable(BaseTable):
class Meta(BaseTable.Meta):
model = DeviceType
fields = (
'pk', 'model', 'manufacturer', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
'instance_count', 'tags',
'pk', 'id', 'model', 'manufacturer', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
'comments', 'instance_count', 'tags',
)
default_columns = (
'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count',
@@ -89,10 +93,16 @@ class DeviceTypeTable(BaseTable):
class ComponentTemplateTable(BaseTable):
pk = ToggleColumn()
id = tables.Column(
verbose_name='ID'
)
name = tables.Column(
order_by=('_name',)
)
class Meta(BaseTable.Meta):
exclude = ('id', )
class ConsolePortTemplateTable(ComponentTemplateTable):
actions = ButtonsColumn(
@@ -101,7 +111,7 @@ class ConsolePortTemplateTable(ComponentTemplateTable):
return_url_extra='%23tab_consoleports'
)
class Meta(BaseTable.Meta):
class Meta(ComponentTemplateTable.Meta):
model = ConsolePortTemplate
fields = ('pk', 'name', 'label', 'type', 'description', 'actions')
empty_text = "None"
@@ -114,7 +124,7 @@ class ConsoleServerPortTemplateTable(ComponentTemplateTable):
return_url_extra='%23tab_consoleserverports'
)
class Meta(BaseTable.Meta):
class Meta(ComponentTemplateTable.Meta):
model = ConsoleServerPortTemplate
fields = ('pk', 'name', 'label', 'type', 'description', 'actions')
empty_text = "None"
@@ -127,7 +137,7 @@ class PowerPortTemplateTable(ComponentTemplateTable):
return_url_extra='%23tab_powerports'
)
class Meta(BaseTable.Meta):
class Meta(ComponentTemplateTable.Meta):
model = PowerPortTemplate
fields = ('pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'actions')
empty_text = "None"
@@ -140,7 +150,7 @@ class PowerOutletTemplateTable(ComponentTemplateTable):
return_url_extra='%23tab_poweroutlets'
)
class Meta(BaseTable.Meta):
class Meta(ComponentTemplateTable.Meta):
model = PowerOutletTemplate
fields = ('pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'actions')
empty_text = "None"
@@ -156,7 +166,7 @@ class InterfaceTemplateTable(ComponentTemplateTable):
return_url_extra='%23tab_interfaces'
)
class Meta(BaseTable.Meta):
class Meta(ComponentTemplateTable.Meta):
model = InterfaceTemplate
fields = ('pk', 'name', 'label', 'mgmt_only', 'type', 'description', 'actions')
empty_text = "None"
@@ -173,7 +183,7 @@ class FrontPortTemplateTable(ComponentTemplateTable):
return_url_extra='%23tab_frontports'
)
class Meta(BaseTable.Meta):
class Meta(ComponentTemplateTable.Meta):
model = FrontPortTemplate
fields = ('pk', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', 'actions')
empty_text = "None"
@@ -187,7 +197,7 @@ class RearPortTemplateTable(ComponentTemplateTable):
return_url_extra='%23tab_rearports'
)
class Meta(BaseTable.Meta):
class Meta(ComponentTemplateTable.Meta):
model = RearPortTemplate
fields = ('pk', 'name', 'label', 'type', 'color', 'positions', 'description', 'actions')
empty_text = "None"
@@ -200,7 +210,7 @@ class DeviceBayTemplateTable(ComponentTemplateTable):
return_url_extra='%23tab_devicebays'
)
class Meta(BaseTable.Meta):
class Meta(ComponentTemplateTable.Meta):
model = DeviceBayTemplate
fields = ('pk', 'name', 'label', 'description', 'actions')
empty_text = "None"

View File

@@ -1,7 +1,7 @@
import django_tables2 as tables
from dcim.models import PowerFeed, PowerPanel
from utilities.tables import BaseTable, ChoiceFieldColumn, LinkedCountColumn, TagColumn, ToggleColumn
from utilities.tables import BaseTable, ChoiceFieldColumn, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn
from .devices import CableTerminationTable
__all__ = (
@@ -33,7 +33,7 @@ class PowerPanelTable(BaseTable):
class Meta(BaseTable.Meta):
model = PowerPanel
fields = ('pk', 'name', 'site', 'location', 'powerfeed_count', 'tags')
fields = ('pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'tags')
default_columns = ('pk', 'name', 'site', 'location', 'powerfeed_count')
@@ -62,6 +62,7 @@ class PowerFeedTable(CableTerminationTable):
available_power = tables.Column(
verbose_name='Available power (VA)'
)
comments = MarkdownColumn()
tags = TagColumn(
url_name='dcim:powerfeed_list'
)
@@ -69,9 +70,9 @@ class PowerFeedTable(CableTerminationTable):
class Meta(BaseTable.Meta):
model = PowerFeed
fields = (
'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase',
'pk', 'id', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase',
'max_utilization', 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'connection', 'available_power',
'tags',
'comments', 'tags',
)
default_columns = (
'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable',

View File

@@ -4,13 +4,12 @@ from django_tables2.utils import Accessor
from dcim.models import Rack, RackReservation, RackRole
from tenancy.tables import TenantColumn
from utilities.tables import (
BaseTable, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, TagColumn,
ToggleColumn, UtilizationColumn,
BaseTable, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, MarkdownColumn,
TagColumn, ToggleColumn, UtilizationColumn,
)
__all__ = (
'RackTable',
'RackDetailTable',
'RackReservationTable',
'RackRoleTable',
)
@@ -29,7 +28,7 @@ class RackRoleTable(BaseTable):
class Meta(BaseTable.Meta):
model = RackRole
fields = ('pk', 'name', 'rack_count', 'color', 'description', 'slug', 'actions')
fields = ('pk', 'id', 'name', 'rack_count', 'color', 'description', 'slug', 'actions')
default_columns = ('pk', 'name', 'rack_count', 'color', 'description', 'actions')
@@ -56,17 +55,7 @@ class RackTable(BaseTable):
template_code="{{ record.u_height }}U",
verbose_name='Height'
)
class Meta(BaseTable.Meta):
model = Rack
fields = (
'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type',
'width', 'u_height',
)
default_columns = ('pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height')
class RackDetailTable(RackTable):
comments = MarkdownColumn()
device_count = LinkedCountColumn(
viewname='dcim:device_list',
url_params={'rack_id': 'pk'},
@@ -84,10 +73,11 @@ class RackDetailTable(RackTable):
url_name='dcim:rack_list'
)
class Meta(RackTable.Meta):
class Meta(BaseTable.Meta):
model = Rack
fields = (
'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type',
'width', 'u_height', 'device_count', 'get_utilization', 'get_power_utilization', 'tags',
'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',
)
default_columns = (
'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count',
@@ -125,7 +115,7 @@ class RackReservationTable(BaseTable):
class Meta(BaseTable.Meta):
model = RackReservation
fields = (
'pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'tags',
'pk', 'id', 'reservation', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'tags',
'actions',
)
default_columns = (

View File

@@ -3,7 +3,7 @@ import django_tables2 as tables
from dcim.models import Location, Region, Site, SiteGroup
from tenancy.tables import TenantColumn
from utilities.tables import (
BaseTable, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, MPTTColumn, TagColumn, ToggleColumn,
BaseTable, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, MarkdownColumn, MPTTColumn, TagColumn, ToggleColumn,
)
from .template_code import LOCATION_ELEVATIONS
@@ -33,7 +33,7 @@ class RegionTable(BaseTable):
class Meta(BaseTable.Meta):
model = Region
fields = ('pk', 'name', 'slug', 'site_count', 'description', 'actions')
fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'actions')
default_columns = ('pk', 'name', 'site_count', 'description', 'actions')
@@ -55,7 +55,7 @@ class SiteGroupTable(BaseTable):
class Meta(BaseTable.Meta):
model = SiteGroup
fields = ('pk', 'name', 'slug', 'site_count', 'description', 'actions')
fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'actions')
default_columns = ('pk', 'name', 'site_count', 'description', 'actions')
@@ -76,6 +76,7 @@ class SiteTable(BaseTable):
linkify=True
)
tenant = TenantColumn()
comments = MarkdownColumn()
tags = TagColumn(
url_name='dcim:site_list'
)
@@ -83,9 +84,9 @@ class SiteTable(BaseTable):
class Meta(BaseTable.Meta):
model = Site
fields = (
'pk', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asn', 'time_zone', 'description',
'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asn', 'time_zone', 'description',
'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone',
'contact_email', 'tags',
'contact_email', 'comments', 'tags',
)
default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'asn', 'description')
@@ -119,5 +120,5 @@ class LocationTable(BaseTable):
class Meta(BaseTable.Meta):
model = Location
fields = ('pk', 'name', 'site', 'rack_count', 'device_count', 'description', 'slug', 'actions')
fields = ('pk', 'id', 'name', 'site', 'rack_count', 'device_count', 'description', 'slug', 'actions')
default_columns = ('pk', 'name', 'site', 'rack_count', 'device_count', 'description', 'actions')

View File

@@ -5,13 +5,11 @@ CABLETERMINATION = """
<i class="mdi mdi-chevron-right"></i>
{% endif %}
<a href="{{ value.get_absolute_url }}">{{ value }}</a>
{% else %}
&mdash;
{% endif %}
"""
CABLE_LENGTH = """
{% if record.length %}{{ record.length }} {{ record.get_length_unit_display }}{% else %}&mdash;{% endif %}
{% if record.length %}{{ record.length }} {{ record.get_length_unit_display }}{% endif %}
"""
CABLE_TERMINATION_PARENT = """
@@ -63,8 +61,6 @@ INTERFACE_TAGGED_VLANS = """
{% endfor %}
{% elif record.mode == 'tagged-all' %}
All
{% else %}
&mdash;
{% endif %}
"""

View File

@@ -1,4 +1,5 @@
from django.contrib.auth.models import User
from django.test import override_settings
from django.urls import reverse
from rest_framework import status
@@ -1490,40 +1491,35 @@ class ConnectedDeviceTest(APITestCase):
super().setUp()
self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
self.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
self.devicetype1 = DeviceType.objects.create(
manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
)
self.devicetype2 = DeviceType.objects.create(
manufacturer=manufacturer, model='Test Device Type 2', slug='test-device-type-2'
)
self.devicerole1 = DeviceRole.objects.create(
name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
)
self.devicerole2 = DeviceRole.objects.create(
name='Test Device Role 2', slug='test-device-role-2', color='00ff00'
)
site = Site.objects.create(name='Site 1', slug='site-1')
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1', color='ff0000')
self.device1 = Device.objects.create(
device_type=self.devicetype1, device_role=self.devicerole1, name='TestDevice1', site=self.site1
device_type=devicetype, device_role=devicerole, name='TestDevice1', site=site
)
self.device2 = Device.objects.create(
device_type=self.devicetype1, device_role=self.devicerole1, name='TestDevice2', site=self.site1
device_type=devicetype, device_role=devicerole, name='TestDevice2', site=site
)
self.interface1 = Interface.objects.create(device=self.device1, name='eth0')
self.interface2 = Interface.objects.create(device=self.device2, name='eth0')
self.interface3 = Interface.objects.create(device=self.device1, name='eth1') # Not connected
cable = Cable(termination_a=self.interface1, termination_b=self.interface2)
cable.save()
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_get_connected_device(self):
url = reverse('dcim-api:connected-device-list')
response = self.client.get(url + '?peer_device=TestDevice2&peer_interface=eth0', **self.header)
url_params = f'?peer_device={self.device1.name}&peer_interface={self.interface1.name}'
response = self.client.get(url + url_params, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['name'], self.device1.name)
self.assertEqual(response.data['name'], self.device2.name)
url_params = f'?peer_device={self.device1.name}&peer_interface={self.interface3.name}'
response = self.client.get(url + url_params, **self.header)
self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)
class VirtualChassisTest(APIViewTestCases.APIViewTestCase):

View File

@@ -2851,6 +2851,9 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
)
Interface.objects.bulk_create(interfaces)
console_port = ConsolePort.objects.create(device=devices[0], name='Console Port 1')
console_server_port = ConsoleServerPort.objects.create(device=devices[0], name='Console Server Port 1')
# Cables
Cable(termination_a=interfaces[1], termination_b=interfaces[2], label='Cable 1', type=CableTypeChoices.TYPE_CAT3, status=CableStatusChoices.STATUS_CONNECTED, color='aa1409', length=10, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
Cable(termination_a=interfaces[3], termination_b=interfaces[4], label='Cable 2', type=CableTypeChoices.TYPE_CAT3, status=CableStatusChoices.STATUS_CONNECTED, color='aa1409', length=20, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
@@ -2858,6 +2861,7 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
Cable(termination_a=interfaces[7], termination_b=interfaces[8], label='Cable 4', type=CableTypeChoices.TYPE_CAT5E, status=CableStatusChoices.STATUS_PLANNED, color='f44336', length=40, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
Cable(termination_a=interfaces[9], termination_b=interfaces[10], label='Cable 5', type=CableTypeChoices.TYPE_CAT6, status=CableStatusChoices.STATUS_PLANNED, color='e91e63', length=10, length_unit=CableLengthUnitChoices.UNIT_METER).save()
Cable(termination_a=interfaces[11], termination_b=interfaces[0], label='Cable 6', type=CableTypeChoices.TYPE_CAT6, status=CableStatusChoices.STATUS_PLANNED, color='e91e63', length=20, length_unit=CableLengthUnitChoices.UNIT_METER).save()
Cable(termination_a=console_port, termination_b=console_server_port, label='Cable 7').save()
def test_label(self):
params = {'label': ['Cable 1', 'Cable 2']}
@@ -2877,7 +2881,7 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
def test_status(self):
params = {'status': [CableStatusChoices.STATUS_CONNECTED]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'status': [CableStatusChoices.STATUS_PLANNED]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
@@ -2888,30 +2892,44 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
def test_device(self):
devices = Device.objects.all()[:2]
params = {'device_id': [devices[0].pk, devices[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'device': [devices[0].name, devices[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_rack(self):
racks = Rack.objects.all()[:2]
params = {'rack_id': [racks[0].pk, racks[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
params = {'rack': [racks[0].name, racks[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
def test_site(self):
site = Site.objects.all()[:2]
params = {'site_id': [site[0].pk, site[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
params = {'site': [site[0].slug, site[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
def test_tenant(self):
tenant = Tenant.objects.all()[:2]
params = {'tenant_id': [tenant[0].pk, tenant[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
params = {'tenant': [tenant[0].slug, tenant[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
def test_termination_types(self):
params = {'termination_a_type': 'dcim.consoleport'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'termination_b_type': 'dcim.consoleserverport'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_termination_ids(self):
interface_ids = Cable.objects.values_list('termination_a_id', flat=True)[:3]
params = {
'termination_a_type': 'dcim.interface',
'termination_a_id': list(interface_ids),
}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
class PowerPanelTestCase(TestCase, ChangeLoggedFilterSetTests):

View File

@@ -1,5 +1,6 @@
from django.test import TestCase
from dcim.choices import DeviceFaceChoices, DeviceStatusChoices, InterfaceTypeChoices
from dcim.forms import *
from dcim.models import *
from virtualization.models import Cluster, ClusterGroup, ClusterType

View File

@@ -322,10 +322,10 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
cls.csv_data = (
"site,location,name,width,u_height",
"Site 1,,Rack 4,19,42",
"Site 1,Location 1,Rack 5,19,42",
"Site 2,Location 2,Rack 6,19,42",
"site,location,name,status,width,u_height",
"Site 1,,Rack 4,active,19,42",
"Site 1,Location 1,Rack 5,active,19,42",
"Site 2,Location 2,Rack 6,active,19,42",
)
cls.bulk_edit_data = {
@@ -1991,10 +1991,10 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
cls.csv_data = (
"site,power_panel,name,voltage,amperage,max_utilization",
"Site 1,Power Panel 1,Power Feed 4,120,20,80",
"Site 1,Power Panel 1,Power Feed 5,120,20,80",
"Site 1,Power Panel 1,Power Feed 6,120,20,80",
"site,power_panel,name,status,type,supply,phase,voltage,amperage,max_utilization",
"Site 1,Power Panel 1,Power Feed 4,active,primary,ac,single-phase,120,20,80",
"Site 1,Power Panel 1,Power Feed 5,active,primary,ac,single-phase,120,20,80",
"Site 1,Power Panel 1,Power Feed 6,active,primary,ac,single-phase,120,20,80",
)
cls.bulk_edit_data = {

View File

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

View File

@@ -1,11 +1,10 @@
import logging
from collections import OrderedDict
from django.contrib import messages
from django.contrib.contenttypes.models import ContentType
from django.core.paginator import EmptyPage, PageNotAnInteger
from django.db import transaction
from django.db.models import F, Prefetch
from django.db.models import Prefetch
from django.forms import ModelMultipleChoiceField, MultipleHiddenInput, modelformset_factory
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
@@ -131,8 +130,7 @@ class RegionView(generic.ObjectView):
sites = Site.objects.restrict(request.user, 'view').filter(
region=instance
)
sites_table = tables.SiteTable(sites)
sites_table.columns.hide('region')
sites_table = tables.SiteTable(sites, exclude=('region',))
paginate_table(sites_table, request)
return {
@@ -216,8 +214,7 @@ class SiteGroupView(generic.ObjectView):
sites = Site.objects.restrict(request.user, 'view').filter(
group=instance
)
sites_table = tables.SiteTable(sites)
sites_table.columns.hide('group')
sites_table = tables.SiteTable(sites, exclude=('group',))
paginate_table(sites_table, request)
return {
@@ -440,6 +437,8 @@ class RackRoleListView(generic.ObjectListView):
queryset = RackRole.objects.annotate(
rack_count=count_related(Rack, 'role')
)
filterset = filtersets.RackRoleFilterSet
filterset_form = forms.RackRoleFilterForm
table = tables.RackRoleTable
@@ -451,8 +450,7 @@ class RackRoleView(generic.ObjectView):
role=instance
)
racks_table = tables.RackTable(racks)
racks_table.columns.hide('role')
racks_table = tables.RackTable(racks, exclude=('role', 'get_utilization', 'get_power_utilization'))
paginate_table(racks_table, request)
return {
@@ -503,7 +501,7 @@ class RackListView(generic.ObjectListView):
)
filterset = filtersets.RackFilterSet
filterset_form = forms.RackFilterForm
table = tables.RackDetailTable
table = tables.RackTable
class RackElevationListView(generic.ObjectListView):
@@ -684,6 +682,8 @@ class ManufacturerListView(generic.ObjectListView):
inventoryitem_count=count_related(InventoryItem, 'manufacturer'),
platform_count=count_related(Platform, 'manufacturer')
)
filterset = filtersets.ManufacturerFilterSet
filterset_form = forms.ManufacturerFilterForm
table = tables.ManufacturerTable
@@ -700,8 +700,7 @@ class ManufacturerView(generic.ObjectView):
manufacturer=instance
)
devicetypes_table = tables.DeviceTypeTable(devicetypes)
devicetypes_table.columns.hide('manufacturer')
devicetypes_table = tables.DeviceTypeTable(devicetypes, exclude=('manufacturer',))
paginate_table(devicetypes_table, request)
return {
@@ -1149,6 +1148,8 @@ class DeviceRoleListView(generic.ObjectListView):
device_count=count_related(Device, 'device_role'),
vm_count=count_related(VirtualMachine, 'role')
)
filterset = filtersets.DeviceRoleFilterSet
filterset_form = forms.DeviceRoleFilterForm
table = tables.DeviceRoleTable
@@ -1159,9 +1160,7 @@ class DeviceRoleView(generic.ObjectView):
devices = Device.objects.restrict(request.user, 'view').filter(
device_role=instance
)
devices_table = tables.DeviceTable(devices)
devices_table.columns.hide('device_role')
devices_table = tables.DeviceTable(devices, exclude=('device_role',))
paginate_table(devices_table, request)
return {
@@ -1225,13 +1224,12 @@ class PlatformView(generic.ObjectView):
devices = Device.objects.restrict(request.user, 'view').filter(
platform=instance
)
devices_table = tables.DeviceTable(devices)
devices_table.columns.hide('platform')
devices_table = tables.DeviceTable(devices, exclude=('platform',))
paginate_table(devices_table, request)
return {
'devices_table': devices_table,
'virtualmachine_count': VirtualMachine.objects.filter(platform=instance).count()
}
@@ -1872,9 +1870,9 @@ class InterfaceView(generic.ObjectView):
child_interfaces = Interface.objects.restrict(request.user, 'view').filter(parent=instance)
child_interfaces_tables = tables.InterfaceTable(
child_interfaces,
exclude=('device', 'parent'),
orderable=False
)
child_interfaces_tables.columns.hide('device')
# Get assigned VLANs and annotate whether each is tagged or untagged
vlans = []
@@ -2171,9 +2169,10 @@ class DeviceBayDepopulateView(generic.ObjectEditView):
removed_device = device_bay.installed_device
device_bay.installed_device = None
device_bay.save()
messages.success(request, "{} has been removed from {}.".format(removed_device, device_bay))
messages.success(request, f"{removed_device} has been removed from {device_bay}.")
return_url = self.get_return_url(request, device_bay.device)
return redirect('dcim:device', pk=device_bay.device.pk)
return redirect(return_url)
return render(request, 'dcim/devicebay_depopulate.html', {
'device_bay': device_bay,
@@ -2411,6 +2410,12 @@ class PathTraceView(generic.ObjectView):
else:
path = related_paths.first()
# No paths found
if path is None:
return {
'path': None
}
# Get the total length of the cable and whether the length is definitive (fully defined)
total_length, is_definitive = path.get_total_length() if path else (None, False)

View File

@@ -1 +0,0 @@
default_app_config = 'extras.apps.ExtrasConfig'

View File

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

View File

@@ -15,6 +15,7 @@ from .models import *
__all__ = (
'ConfigContextFilterSet',
'ContentTypeFilterSet',
'CustomFieldFilterSet',
'CustomLinkFilterSet',
'ExportTemplateFilterSet',
'ImageAttachmentFilterSet',
@@ -47,7 +48,7 @@ class WebhookFilterSet(BaseFilterSet):
]
class CustomFieldFilterSet(django_filters.FilterSet):
class CustomFieldFilterSet(BaseFilterSet):
content_types = ContentTypeFilter()
class Meta:

View File

@@ -1,982 +0,0 @@
from django import forms
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.utils.safestring import mark_safe
from django.utils.translation import gettext as _
from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup
from tenancy.models import Tenant, TenantGroup
from utilities.forms import (
add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorField,
CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, CSVContentTypeField, CSVModelForm,
CSVMultipleContentTypeField, DateTimePicker, DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect,
StaticSelectMultiple, BOOLEAN_WITH_BLANK_CHOICES,
)
from virtualization.models import Cluster, ClusterGroup
from .choices import *
from .models import *
from .utils import FeatureQuery
#
# Custom fields
#
class CustomFieldForm(BootstrapMixin, forms.ModelForm):
content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_fields')
)
class Meta:
model = CustomField
fields = '__all__'
fieldsets = (
('Custom Field', ('name', 'label', 'type', 'weight', 'required', 'description')),
('Assigned Models', ('content_types',)),
('Behavior', ('filter_logic',)),
('Values', ('default', 'choices')),
('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')),
)
class CustomFieldCSVForm(CSVModelForm):
content_types = CSVMultipleContentTypeField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_fields'),
help_text="One or more assigned object types"
)
class Meta:
model = CustomField
fields = (
'name', 'label', 'type', 'content_types', 'required', 'description', 'weight', 'filter_logic', 'default',
'weight',
)
class CustomFieldBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=CustomField.objects.all(),
widget=forms.MultipleHiddenInput
)
description = forms.CharField(
required=False
)
required = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
)
weight = forms.IntegerField(
required=False
)
class Meta:
nullable_fields = []
class CustomFieldFilterForm(BootstrapMixin, forms.Form):
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'),
required=False
)
type = forms.MultipleChoiceField(
choices=CustomFieldTypeChoices,
required=False,
widget=StaticSelectMultiple(),
label=_('Field type')
)
weight = forms.IntegerField(
required=False
)
required = forms.NullBooleanField(
required=False,
widget=StaticSelect(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
#
# Custom links
#
class CustomLinkForm(BootstrapMixin, forms.ModelForm):
content_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_links')
)
class Meta:
model = CustomLink
fields = '__all__'
fieldsets = (
('Custom Link', ('name', 'content_type', 'weight', 'group_name', 'button_class', 'new_window')),
('Templates', ('link_text', 'link_url')),
)
widgets = {
'link_text': forms.Textarea(attrs={'class': 'font-monospace'}),
'link_url': forms.Textarea(attrs={'class': 'font-monospace'}),
}
help_texts = {
'link_text': 'Jinja2 template code for the link text. Reference the object as <code>{{ obj }}</code>. '
'Links which render as empty text will not be displayed.',
'link_url': 'Jinja2 template code for the link URL. Reference the object as <code>{{ obj }}</code>.',
}
class CustomLinkCSVForm(CSVModelForm):
content_type = CSVContentTypeField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_links'),
help_text="Assigned object type"
)
class Meta:
model = CustomLink
fields = (
'name', 'content_type', 'weight', 'group_name', 'button_class', 'new_window', 'link_text', 'link_url',
)
class CustomLinkBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=CustomLink.objects.all(),
widget=forms.MultipleHiddenInput
)
content_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_fields'),
required=False
)
new_window = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
)
weight = forms.IntegerField(
required=False
)
button_class = forms.ChoiceField(
choices=CustomLinkButtonClassChoices,
required=False,
widget=StaticSelect()
)
class Meta:
nullable_fields = []
class CustomLinkFilterForm(BootstrapMixin, forms.Form):
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'),
required=False
)
weight = forms.IntegerField(
required=False
)
new_window = forms.NullBooleanField(
required=False,
widget=StaticSelect(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
#
# Export templates
#
class ExportTemplateForm(BootstrapMixin, forms.ModelForm):
content_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_links')
)
class Meta:
model = ExportTemplate
fields = '__all__'
fieldsets = (
('Custom Link', ('name', 'content_type', 'description')),
('Template', ('template_code',)),
('Rendering', ('mime_type', 'file_extension', 'as_attachment')),
)
widgets = {
'template_code': forms.Textarea(attrs={'class': 'font-monospace'}),
}
class ExportTemplateCSVForm(CSVModelForm):
content_type = CSVContentTypeField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('export_templates'),
help_text="Assigned object type"
)
class Meta:
model = ExportTemplate
fields = (
'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment', 'template_code',
)
class ExportTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=ExportTemplate.objects.all(),
widget=forms.MultipleHiddenInput
)
content_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_fields'),
required=False
)
description = forms.CharField(
max_length=200,
required=False
)
mime_type = forms.CharField(
max_length=50,
required=False
)
file_extension = forms.CharField(
max_length=15,
required=False
)
as_attachment = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
)
class Meta:
nullable_fields = ['description', 'mime_type', 'file_extension']
class ExportTemplateFilterForm(BootstrapMixin, forms.Form):
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'),
required=False
)
mime_type = forms.CharField(
required=False,
label=_('MIME type')
)
file_extension = forms.CharField(
required=False
)
as_attachment = forms.NullBooleanField(
required=False,
widget=StaticSelect(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
#
# Webhooks
#
class WebhookForm(BootstrapMixin, forms.ModelForm):
content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('webhooks')
)
class Meta:
model = Webhook
fields = '__all__'
fieldsets = (
('Webhook', ('name', 'enabled')),
('Assigned Models', ('content_types',)),
('Events', ('type_create', 'type_update', 'type_delete')),
('HTTP Request', (
'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret',
)),
('SSL', ('ssl_verification', 'ca_file_path')),
)
widgets = {
'additional_headers': forms.Textarea(attrs={'class': 'font-monospace'}),
'body_template': forms.Textarea(attrs={'class': 'font-monospace'}),
}
class WebhookCSVForm(CSVModelForm):
content_types = CSVMultipleContentTypeField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('webhooks'),
help_text="One or more assigned object types"
)
class Meta:
model = Webhook
fields = (
'name', 'enabled', 'content_types', 'type_create', 'type_update', 'type_delete', 'payload_url',
'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret', 'ssl_verification',
'ca_file_path'
)
class WebhookBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Webhook.objects.all(),
widget=forms.MultipleHiddenInput
)
enabled = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
)
type_create = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
)
type_update = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
)
type_delete = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
)
http_method = forms.ChoiceField(
choices=WebhookHttpMethodChoices,
required=False
)
payload_url = forms.CharField(
required=False
)
ssl_verification = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
)
secret = forms.CharField(
required=False
)
ca_file_path = forms.CharField(
required=False
)
class Meta:
nullable_fields = ['secret', 'ca_file_path']
class WebhookFilterForm(BootstrapMixin, forms.Form):
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'),
required=False
)
http_method = forms.MultipleChoiceField(
choices=WebhookHttpMethodChoices,
required=False,
widget=StaticSelectMultiple(),
label=_('HTTP method')
)
enabled = forms.NullBooleanField(
required=False,
widget=StaticSelect(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
type_create = forms.NullBooleanField(
required=False,
widget=StaticSelect(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
type_update = forms.NullBooleanField(
required=False,
widget=StaticSelect(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
type_delete = forms.NullBooleanField(
required=False,
widget=StaticSelect(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
#
# Custom field models
#
class CustomFieldsMixin:
"""
Extend a Form to include custom field support.
"""
def __init__(self, *args, **kwargs):
self.custom_fields = []
super().__init__(*args, **kwargs)
self._append_customfield_fields()
def _get_content_type(self):
"""
Return the ContentType of the form's model.
"""
if not hasattr(self, 'model'):
raise NotImplementedError(f"{self.__class__.__name__} must specify a model class.")
return ContentType.objects.get_for_model(self.model)
def _get_form_field(self, customfield):
return customfield.to_form_field()
def _append_customfield_fields(self):
"""
Append form fields for all CustomFields assigned to this object type.
"""
content_type = self._get_content_type()
# Append form fields; assign initial values if modifying and existing object
for customfield in CustomField.objects.filter(content_types=content_type):
field_name = f'cf_{customfield.name}'
self.fields[field_name] = self._get_form_field(customfield)
# Annotate the field in the list of CustomField form fields
self.custom_fields.append(field_name)
class CustomFieldModelForm(CustomFieldsMixin, forms.ModelForm):
"""
Extend ModelForm to include custom field support.
"""
def _get_content_type(self):
return ContentType.objects.get_for_model(self._meta.model)
def _get_form_field(self, customfield):
if self.instance.pk:
form_field = customfield.to_form_field(set_initial=False)
form_field.initial = self.instance.custom_field_data.get(customfield.name, None)
return form_field
return customfield.to_form_field()
def clean(self):
# Save custom field data on instance
for cf_name in self.custom_fields:
key = cf_name[3:] # Strip "cf_" from field name
value = self.cleaned_data.get(cf_name)
empty_values = self.fields[cf_name].empty_values
# Convert "empty" values to null
self.instance.custom_field_data[key] = value if value not in empty_values else None
return super().clean()
class CustomFieldModelCSVForm(CSVModelForm, CustomFieldModelForm):
def _get_form_field(self, customfield):
return customfield.to_form_field(for_csv_import=True)
class CustomFieldModelBulkEditForm(BulkEditForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.custom_fields = []
self.obj_type = ContentType.objects.get_for_model(self.model)
# Add all applicable CustomFields to the form
custom_fields = CustomField.objects.filter(content_types=self.obj_type)
for cf in custom_fields:
# Annotate non-required custom fields as nullable
if not cf.required:
self.nullable_fields.append(cf.name)
self.fields[cf.name] = cf.to_form_field(set_initial=False, enforce_required=False)
# Annotate this as a custom field
self.custom_fields.append(cf.name)
class CustomFieldModelFilterForm(forms.Form):
def __init__(self, *args, **kwargs):
self.obj_type = ContentType.objects.get_for_model(self.model)
super().__init__(*args, **kwargs)
# Add all applicable CustomFields to the form
self.custom_field_filters = []
custom_fields = CustomField.objects.filter(content_types=self.obj_type).exclude(
filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED
)
for cf in custom_fields:
field_name = 'cf_{}'.format(cf.name)
self.fields[field_name] = cf.to_form_field(set_initial=True, enforce_required=False)
self.custom_field_filters.append(field_name)
#
# Tags
#
class TagForm(BootstrapMixin, forms.ModelForm):
slug = SlugField()
class Meta:
model = Tag
fields = [
'name', 'slug', 'color', 'description'
]
fieldsets = (
('Tag', ('name', 'slug', 'color', 'description')),
)
class TagCSVForm(CSVModelForm):
slug = SlugField()
class Meta:
model = Tag
fields = ('name', 'slug', 'color', 'description')
help_texts = {
'color': mark_safe('RGB color in hexadecimal (e.g. <code>00ff00</code>)'),
}
class AddRemoveTagsForm(forms.Form):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Add add/remove tags fields
self.fields['add_tags'] = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
self.fields['remove_tags'] = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class TagFilterForm(BootstrapMixin, forms.Form):
model = Tag
q = forms.CharField(
required=False,
label=_('Search')
)
content_type_id = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()),
required=False,
label=_('Tagged object type')
)
class TagBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Tag.objects.all(),
widget=forms.MultipleHiddenInput
)
color = ColorField(
required=False
)
description = forms.CharField(
max_length=200,
required=False
)
class Meta:
nullable_fields = ['description']
#
# Config contexts
#
class ConfigContextForm(BootstrapMixin, forms.ModelForm):
regions = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False
)
site_groups = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False
)
sites = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
required=False
)
device_types = DynamicModelMultipleChoiceField(
queryset=DeviceType.objects.all(),
required=False
)
roles = DynamicModelMultipleChoiceField(
queryset=DeviceRole.objects.all(),
required=False
)
platforms = DynamicModelMultipleChoiceField(
queryset=Platform.objects.all(),
required=False
)
cluster_groups = DynamicModelMultipleChoiceField(
queryset=ClusterGroup.objects.all(),
required=False
)
clusters = DynamicModelMultipleChoiceField(
queryset=Cluster.objects.all(),
required=False
)
tenant_groups = DynamicModelMultipleChoiceField(
queryset=TenantGroup.objects.all(),
required=False
)
tenants = DynamicModelMultipleChoiceField(
queryset=Tenant.objects.all(),
required=False
)
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
data = JSONField(
label=''
)
class Meta:
model = ConfigContext
fields = (
'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites', 'roles', 'device_types',
'platforms', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data',
)
class ConfigContextBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=ConfigContext.objects.all(),
widget=forms.MultipleHiddenInput
)
weight = forms.IntegerField(
required=False,
min_value=0
)
is_active = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
)
description = forms.CharField(
required=False,
max_length=100
)
class Meta:
nullable_fields = [
'description',
]
class ConfigContextFilterForm(BootstrapMixin, forms.Form):
field_groups = [
['q', 'tag'],
['region_id', 'site_group_id', 'site_id'],
['device_type_id', 'platform_id', 'role_id'],
['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,
label=_('Regions'),
fetch_trigger='open'
)
site_group_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
label=_('Site groups'),
fetch_trigger='open'
)
site_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
required=False,
label=_('Sites'),
fetch_trigger='open'
)
device_type_id = DynamicModelMultipleChoiceField(
queryset=DeviceType.objects.all(),
required=False,
label=_('Device types'),
fetch_trigger='open'
)
role_id = DynamicModelMultipleChoiceField(
queryset=DeviceRole.objects.all(),
required=False,
label=_('Roles'),
fetch_trigger='open'
)
platform_id = DynamicModelMultipleChoiceField(
queryset=Platform.objects.all(),
required=False,
label=_('Platforms'),
fetch_trigger='open'
)
cluster_group_id = DynamicModelMultipleChoiceField(
queryset=ClusterGroup.objects.all(),
required=False,
label=_('Cluster groups'),
fetch_trigger='open'
)
cluster_id = DynamicModelMultipleChoiceField(
queryset=Cluster.objects.all(),
required=False,
label=_('Clusters'),
fetch_trigger='open'
)
tenant_group_id = DynamicModelMultipleChoiceField(
queryset=TenantGroup.objects.all(),
required=False,
label=_('Tenant groups'),
fetch_trigger='open'
)
tenant_id = DynamicModelMultipleChoiceField(
queryset=Tenant.objects.all(),
required=False,
label=_('Tenant'),
fetch_trigger='open'
)
tag = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
to_field_name='slug',
required=False,
label=_('Tags'),
fetch_trigger='open'
)
#
# Filter form for local config context data
#
class LocalConfigContextFilterForm(forms.Form):
local_context_data = forms.NullBooleanField(
required=False,
label=_('Has local config context data'),
widget=StaticSelect(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
#
# Image attachments
#
class ImageAttachmentForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = ImageAttachment
fields = [
'name', 'image',
]
#
# Journal entries
#
class JournalEntryForm(BootstrapMixin, forms.ModelForm):
comments = CommentField()
kind = forms.ChoiceField(
choices=add_blank_choice(JournalEntryKindChoices),
required=False,
widget=StaticSelect()
)
class Meta:
model = JournalEntry
fields = ['assigned_object_type', 'assigned_object_id', 'kind', 'comments']
widgets = {
'assigned_object_type': forms.HiddenInput,
'assigned_object_id': forms.HiddenInput,
}
class JournalEntryBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=JournalEntry.objects.all(),
widget=forms.MultipleHiddenInput
)
kind = forms.ChoiceField(
choices=JournalEntryKindChoices,
required=False
)
comments = forms.CharField(
required=False,
widget=forms.Textarea()
)
class Meta:
nullable_fields = []
class JournalEntryFilterForm(BootstrapMixin, forms.Form):
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'),
widget=DateTimePicker()
)
created_before = forms.DateTimeField(
required=False,
label=_('Before'),
widget=DateTimePicker()
)
created_by_id = DynamicModelMultipleChoiceField(
queryset=User.objects.all(),
required=False,
label=_('User'),
widget=APISelectMultiple(
api_url='/api/users/users/',
),
fetch_trigger='open'
)
assigned_object_type_id = DynamicModelMultipleChoiceField(
queryset=ContentType.objects.all(),
required=False,
label=_('Object Type'),
widget=APISelectMultiple(
api_url='/api/extras/content-types/',
),
fetch_trigger='open'
)
kind = forms.ChoiceField(
choices=add_blank_choice(JournalEntryKindChoices),
required=False,
widget=StaticSelect()
)
#
# Change logging
#
class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
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'),
widget=DateTimePicker()
)
time_before = forms.DateTimeField(
required=False,
label=_('Before'),
widget=DateTimePicker()
)
action = forms.ChoiceField(
choices=add_blank_choice(ObjectChangeActionChoices),
required=False,
widget=StaticSelect()
)
user_id = DynamicModelMultipleChoiceField(
queryset=User.objects.all(),
required=False,
label=_('User'),
widget=APISelectMultiple(
api_url='/api/users/users/',
),
fetch_trigger='open'
)
changed_object_type_id = DynamicModelMultipleChoiceField(
queryset=ContentType.objects.all(),
required=False,
label=_('Object Type'),
widget=APISelectMultiple(
api_url='/api/extras/content-types/',
),
fetch_trigger='open'
)
#
# Scripts
#
class ScriptForm(BootstrapMixin, forms.Form):
_commit = forms.BooleanField(
required=False,
initial=True,
label="Commit changes",
help_text="Commit changes to the database (uncheck for a dry-run)"
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Move _commit to the end of the form
commit = self.fields.pop('_commit')
self.fields['_commit'] = commit
@property
def requires_input(self):
"""
A boolean indicating whether the form requires user input (ignore the _commit field).
"""
return bool(len(self.fields) > 1)

View File

@@ -0,0 +1,6 @@
from .models import *
from .filtersets import *
from .bulk_edit import *
from .bulk_import import *
from .customfields import *
from .scripts import *

View File

@@ -0,0 +1,199 @@
from django import forms
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,
)
__all__ = (
'ConfigContextBulkEditForm',
'CustomFieldBulkEditForm',
'CustomLinkBulkEditForm',
'ExportTemplateBulkEditForm',
'JournalEntryBulkEditForm',
'TagBulkEditForm',
'WebhookBulkEditForm',
)
class CustomFieldBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=CustomField.objects.all(),
widget=forms.MultipleHiddenInput
)
description = forms.CharField(
required=False
)
required = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
)
weight = forms.IntegerField(
required=False
)
class Meta:
nullable_fields = []
class CustomLinkBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=CustomLink.objects.all(),
widget=forms.MultipleHiddenInput
)
content_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_fields'),
required=False
)
new_window = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
)
weight = forms.IntegerField(
required=False
)
button_class = forms.ChoiceField(
choices=CustomLinkButtonClassChoices,
required=False,
widget=StaticSelect()
)
class Meta:
nullable_fields = []
class ExportTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=ExportTemplate.objects.all(),
widget=forms.MultipleHiddenInput
)
content_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_fields'),
required=False
)
description = forms.CharField(
max_length=200,
required=False
)
mime_type = forms.CharField(
max_length=50,
required=False
)
file_extension = forms.CharField(
max_length=15,
required=False
)
as_attachment = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
)
class Meta:
nullable_fields = ['description', 'mime_type', 'file_extension']
class WebhookBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Webhook.objects.all(),
widget=forms.MultipleHiddenInput
)
enabled = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
)
type_create = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
)
type_update = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
)
type_delete = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
)
http_method = forms.ChoiceField(
choices=WebhookHttpMethodChoices,
required=False
)
payload_url = forms.CharField(
required=False
)
ssl_verification = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
)
secret = forms.CharField(
required=False
)
ca_file_path = forms.CharField(
required=False
)
class Meta:
nullable_fields = ['secret', 'ca_file_path']
class TagBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Tag.objects.all(),
widget=forms.MultipleHiddenInput
)
color = ColorField(
required=False
)
description = forms.CharField(
max_length=200,
required=False
)
class Meta:
nullable_fields = ['description']
class ConfigContextBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=ConfigContext.objects.all(),
widget=forms.MultipleHiddenInput
)
weight = forms.IntegerField(
required=False,
min_value=0
)
is_active = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
)
description = forms.CharField(
required=False,
max_length=100
)
class Meta:
nullable_fields = [
'description',
]
class JournalEntryBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=JournalEntry.objects.all(),
widget=forms.MultipleHiddenInput
)
kind = forms.ChoiceField(
choices=JournalEntryKindChoices,
required=False
)
comments = forms.CharField(
required=False,
widget=forms.Textarea()
)
class Meta:
nullable_fields = []

View File

@@ -0,0 +1,91 @@
from django import forms
from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.forms import SimpleArrayField
from django.utils.safestring import mark_safe
from extras.models import *
from extras.utils import FeatureQuery
from utilities.forms import CSVContentTypeField, CSVModelForm, CSVMultipleContentTypeField, SlugField
__all__ = (
'CustomFieldCSVForm',
'CustomLinkCSVForm',
'ExportTemplateCSVForm',
'TagCSVForm',
'WebhookCSVForm',
)
class CustomFieldCSVForm(CSVModelForm):
content_types = CSVMultipleContentTypeField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_fields'),
help_text="One or more assigned object types"
)
choices = SimpleArrayField(
base_field=forms.CharField(),
required=False,
help_text='Comma-separated list of field choices'
)
class Meta:
model = CustomField
fields = (
'name', 'label', 'type', 'content_types', 'required', 'description', 'weight', 'filter_logic', 'default',
'choices', 'weight',
)
class CustomLinkCSVForm(CSVModelForm):
content_type = CSVContentTypeField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_links'),
help_text="Assigned object type"
)
class Meta:
model = CustomLink
fields = (
'name', 'content_type', 'weight', 'group_name', 'button_class', 'new_window', 'link_text', 'link_url',
)
class ExportTemplateCSVForm(CSVModelForm):
content_type = CSVContentTypeField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('export_templates'),
help_text="Assigned object type"
)
class Meta:
model = ExportTemplate
fields = (
'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment', 'template_code',
)
class WebhookCSVForm(CSVModelForm):
content_types = CSVMultipleContentTypeField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('webhooks'),
help_text="One or more assigned object types"
)
class Meta:
model = Webhook
fields = (
'name', 'enabled', 'content_types', 'type_create', 'type_update', 'type_delete', 'payload_url',
'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret', 'ssl_verification',
'ca_file_path'
)
class TagCSVForm(CSVModelForm):
slug = SlugField()
class Meta:
model = Tag
fields = ('name', 'slug', 'color', 'description')
help_texts = {
'color': mark_safe('RGB color in hexadecimal (e.g. <code>00ff00</code>)'),
}

View File

@@ -0,0 +1,123 @@
from django import forms
from django.contrib.contenttypes.models import ContentType
from extras.choices import *
from extras.models import *
from utilities.forms import BulkEditForm, CSVModelForm
__all__ = (
'CustomFieldModelCSVForm',
'CustomFieldModelBulkEditForm',
'CustomFieldModelFilterForm',
'CustomFieldModelForm',
'CustomFieldsMixin',
)
class CustomFieldsMixin:
"""
Extend a Form to include custom field support.
"""
def __init__(self, *args, **kwargs):
self.custom_fields = []
super().__init__(*args, **kwargs)
self._append_customfield_fields()
def _get_content_type(self):
"""
Return the ContentType of the form's model.
"""
if not hasattr(self, 'model'):
raise NotImplementedError(f"{self.__class__.__name__} must specify a model class.")
return ContentType.objects.get_for_model(self.model)
def _get_form_field(self, customfield):
return customfield.to_form_field()
def _append_customfield_fields(self):
"""
Append form fields for all CustomFields assigned to this object type.
"""
content_type = self._get_content_type()
# Append form fields; assign initial values if modifying and existing object
for customfield in CustomField.objects.filter(content_types=content_type):
field_name = f'cf_{customfield.name}'
self.fields[field_name] = self._get_form_field(customfield)
# Annotate the field in the list of CustomField form fields
self.custom_fields.append(field_name)
class CustomFieldModelForm(CustomFieldsMixin, forms.ModelForm):
"""
Extend ModelForm to include custom field support.
"""
def _get_content_type(self):
return ContentType.objects.get_for_model(self._meta.model)
def _get_form_field(self, customfield):
if self.instance.pk:
form_field = customfield.to_form_field(set_initial=False)
form_field.initial = self.instance.custom_field_data.get(customfield.name, None)
return form_field
return customfield.to_form_field()
def clean(self):
# Save custom field data on instance
for cf_name in self.custom_fields:
key = cf_name[3:] # Strip "cf_" from field name
value = self.cleaned_data.get(cf_name)
empty_values = self.fields[cf_name].empty_values
# Convert "empty" values to null
self.instance.custom_field_data[key] = value if value not in empty_values else None
return super().clean()
class CustomFieldModelCSVForm(CSVModelForm, CustomFieldModelForm):
def _get_form_field(self, customfield):
return customfield.to_form_field(for_csv_import=True)
class CustomFieldModelBulkEditForm(BulkEditForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.custom_fields = []
self.obj_type = ContentType.objects.get_for_model(self.model)
# Add all applicable CustomFields to the form
custom_fields = CustomField.objects.filter(content_types=self.obj_type)
for cf in custom_fields:
# Annotate non-required custom fields as nullable
if not cf.required:
self.nullable_fields.append(cf.name)
self.fields[cf.name] = cf.to_form_field(set_initial=False, enforce_required=False)
# Annotate this as a custom field
self.custom_fields.append(cf.name)
class CustomFieldModelFilterForm(forms.Form):
def __init__(self, *args, **kwargs):
self.obj_type = ContentType.objects.get_for_model(self.model)
super().__init__(*args, **kwargs)
# Add all applicable CustomFields to the form
self.custom_field_filters = []
custom_fields = CustomField.objects.filter(content_types=self.obj_type).exclude(
filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED
)
for cf in custom_fields:
field_name = 'cf_{}'.format(cf.name)
self.fields[field_name] = cf.to_form_field(set_initial=True, enforce_required=False)
self.custom_field_filters.append(field_name)

View File

@@ -0,0 +1,364 @@
from django import forms
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext as _
from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup
from extras.choices import *
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,
)
from virtualization.models import Cluster, ClusterGroup
__all__ = (
'ConfigContextFilterForm',
'CustomFieldFilterForm',
'CustomLinkFilterForm',
'ExportTemplateFilterForm',
'JournalEntryFilterForm',
'LocalConfigContextFilterForm',
'ObjectChangeFilterForm',
'TagFilterForm',
'WebhookFilterForm',
)
class CustomFieldFilterForm(BootstrapMixin, forms.Form):
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'),
required=False
)
type = forms.MultipleChoiceField(
choices=CustomFieldTypeChoices,
required=False,
widget=StaticSelectMultiple(),
label=_('Field type')
)
weight = forms.IntegerField(
required=False
)
required = forms.NullBooleanField(
required=False,
widget=StaticSelect(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
class CustomLinkFilterForm(BootstrapMixin, forms.Form):
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'),
required=False
)
weight = forms.IntegerField(
required=False
)
new_window = forms.NullBooleanField(
required=False,
widget=StaticSelect(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
class ExportTemplateFilterForm(BootstrapMixin, forms.Form):
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'),
required=False
)
mime_type = forms.CharField(
required=False,
label=_('MIME type')
)
file_extension = forms.CharField(
required=False
)
as_attachment = forms.NullBooleanField(
required=False,
widget=StaticSelect(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
class WebhookFilterForm(BootstrapMixin, forms.Form):
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'),
required=False
)
http_method = forms.MultipleChoiceField(
choices=WebhookHttpMethodChoices,
required=False,
widget=StaticSelectMultiple(),
label=_('HTTP method')
)
enabled = forms.NullBooleanField(
required=False,
widget=StaticSelect(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
type_create = forms.NullBooleanField(
required=False,
widget=StaticSelect(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
type_update = forms.NullBooleanField(
required=False,
widget=StaticSelect(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
type_delete = forms.NullBooleanField(
required=False,
widget=StaticSelect(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
class TagFilterForm(BootstrapMixin, forms.Form):
model = Tag
q = forms.CharField(
required=False,
label=_('Search')
)
content_type_id = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()),
required=False,
label=_('Tagged object type')
)
class ConfigContextFilterForm(BootstrapMixin, forms.Form):
field_groups = [
['q', 'tag'],
['region_id', 'site_group_id', 'site_id'],
['device_type_id', 'platform_id', 'role_id'],
['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,
label=_('Regions'),
fetch_trigger='open'
)
site_group_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
label=_('Site groups'),
fetch_trigger='open'
)
site_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
required=False,
label=_('Sites'),
fetch_trigger='open'
)
device_type_id = DynamicModelMultipleChoiceField(
queryset=DeviceType.objects.all(),
required=False,
label=_('Device types'),
fetch_trigger='open'
)
role_id = DynamicModelMultipleChoiceField(
queryset=DeviceRole.objects.all(),
required=False,
label=_('Roles'),
fetch_trigger='open'
)
platform_id = DynamicModelMultipleChoiceField(
queryset=Platform.objects.all(),
required=False,
label=_('Platforms'),
fetch_trigger='open'
)
cluster_group_id = DynamicModelMultipleChoiceField(
queryset=ClusterGroup.objects.all(),
required=False,
label=_('Cluster groups'),
fetch_trigger='open'
)
cluster_id = DynamicModelMultipleChoiceField(
queryset=Cluster.objects.all(),
required=False,
label=_('Clusters'),
fetch_trigger='open'
)
tenant_group_id = DynamicModelMultipleChoiceField(
queryset=TenantGroup.objects.all(),
required=False,
label=_('Tenant groups'),
fetch_trigger='open'
)
tenant_id = DynamicModelMultipleChoiceField(
queryset=Tenant.objects.all(),
required=False,
label=_('Tenant'),
fetch_trigger='open'
)
tag = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
to_field_name='slug',
required=False,
label=_('Tags'),
fetch_trigger='open'
)
class LocalConfigContextFilterForm(forms.Form):
local_context_data = forms.NullBooleanField(
required=False,
label=_('Has local config context data'),
widget=StaticSelect(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
class JournalEntryFilterForm(BootstrapMixin, forms.Form):
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'),
widget=DateTimePicker()
)
created_before = forms.DateTimeField(
required=False,
label=_('Before'),
widget=DateTimePicker()
)
created_by_id = DynamicModelMultipleChoiceField(
queryset=User.objects.all(),
required=False,
label=_('User'),
widget=APISelectMultiple(
api_url='/api/users/users/',
),
fetch_trigger='open'
)
assigned_object_type_id = DynamicModelMultipleChoiceField(
queryset=ContentType.objects.all(),
required=False,
label=_('Object Type'),
widget=APISelectMultiple(
api_url='/api/extras/content-types/',
),
fetch_trigger='open'
)
kind = forms.ChoiceField(
choices=add_blank_choice(JournalEntryKindChoices),
required=False,
widget=StaticSelect()
)
class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
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'),
widget=DateTimePicker()
)
time_before = forms.DateTimeField(
required=False,
label=_('Before'),
widget=DateTimePicker()
)
action = forms.ChoiceField(
choices=add_blank_choice(ObjectChangeActionChoices),
required=False,
widget=StaticSelect()
)
user_id = DynamicModelMultipleChoiceField(
queryset=User.objects.all(),
required=False,
label=_('User'),
widget=APISelectMultiple(
api_url='/api/users/users/',
),
fetch_trigger='open'
)
changed_object_type_id = DynamicModelMultipleChoiceField(
queryset=ContentType.objects.all(),
required=False,
label=_('Object Type'),
widget=APISelectMultiple(
api_url='/api/extras/content-types/',
),
fetch_trigger='open'
)

View File

@@ -0,0 +1,223 @@
from django import forms
from django.contrib.contenttypes.models import ContentType
from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup
from extras.choices import *
from extras.models import *
from extras.utils import FeatureQuery
from tenancy.models import Tenant, TenantGroup
from utilities.forms import (
add_blank_choice, BootstrapMixin, CommentField, ContentTypeChoiceField,
ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect,
)
from virtualization.models import Cluster, ClusterGroup
__all__ = (
'AddRemoveTagsForm',
'ConfigContextForm',
'CustomFieldForm',
'CustomLinkForm',
'ExportTemplateForm',
'ImageAttachmentForm',
'JournalEntryForm',
'TagForm',
'WebhookForm',
)
class CustomFieldForm(BootstrapMixin, forms.ModelForm):
content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_fields')
)
class Meta:
model = CustomField
fields = '__all__'
fieldsets = (
('Custom Field', ('name', 'label', 'type', 'weight', 'required', 'description')),
('Assigned Models', ('content_types',)),
('Behavior', ('filter_logic',)),
('Values', ('default', 'choices')),
('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')),
)
class CustomLinkForm(BootstrapMixin, forms.ModelForm):
content_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_links')
)
class Meta:
model = CustomLink
fields = '__all__'
fieldsets = (
('Custom Link', ('name', 'content_type', 'weight', 'group_name', 'button_class', 'new_window')),
('Templates', ('link_text', 'link_url')),
)
widgets = {
'link_text': forms.Textarea(attrs={'class': 'font-monospace'}),
'link_url': forms.Textarea(attrs={'class': 'font-monospace'}),
}
help_texts = {
'link_text': 'Jinja2 template code for the link text. Reference the object as <code>{{ obj }}</code>. '
'Links which render as empty text will not be displayed.',
'link_url': 'Jinja2 template code for the link URL. Reference the object as <code>{{ obj }}</code>.',
}
class ExportTemplateForm(BootstrapMixin, forms.ModelForm):
content_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_links')
)
class Meta:
model = ExportTemplate
fields = '__all__'
fieldsets = (
('Custom Link', ('name', 'content_type', 'description')),
('Template', ('template_code',)),
('Rendering', ('mime_type', 'file_extension', 'as_attachment')),
)
widgets = {
'template_code': forms.Textarea(attrs={'class': 'font-monospace'}),
}
class WebhookForm(BootstrapMixin, forms.ModelForm):
content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('webhooks')
)
class Meta:
model = Webhook
fields = '__all__'
fieldsets = (
('Webhook', ('name', 'enabled')),
('Assigned Models', ('content_types',)),
('Events', ('type_create', 'type_update', 'type_delete')),
('HTTP Request', (
'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret',
)),
('SSL', ('ssl_verification', 'ca_file_path')),
)
widgets = {
'additional_headers': forms.Textarea(attrs={'class': 'font-monospace'}),
'body_template': forms.Textarea(attrs={'class': 'font-monospace'}),
}
class TagForm(BootstrapMixin, forms.ModelForm):
slug = SlugField()
class Meta:
model = Tag
fields = [
'name', 'slug', 'color', 'description'
]
fieldsets = (
('Tag', ('name', 'slug', 'color', 'description')),
)
class AddRemoveTagsForm(forms.Form):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Add add/remove tags fields
self.fields['add_tags'] = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
self.fields['remove_tags'] = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class ConfigContextForm(BootstrapMixin, forms.ModelForm):
regions = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False
)
site_groups = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False
)
sites = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
required=False
)
device_types = DynamicModelMultipleChoiceField(
queryset=DeviceType.objects.all(),
required=False
)
roles = DynamicModelMultipleChoiceField(
queryset=DeviceRole.objects.all(),
required=False
)
platforms = DynamicModelMultipleChoiceField(
queryset=Platform.objects.all(),
required=False
)
cluster_groups = DynamicModelMultipleChoiceField(
queryset=ClusterGroup.objects.all(),
required=False
)
clusters = DynamicModelMultipleChoiceField(
queryset=Cluster.objects.all(),
required=False
)
tenant_groups = DynamicModelMultipleChoiceField(
queryset=TenantGroup.objects.all(),
required=False
)
tenants = DynamicModelMultipleChoiceField(
queryset=Tenant.objects.all(),
required=False
)
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
data = JSONField(
label=''
)
class Meta:
model = ConfigContext
fields = (
'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites', 'roles', 'device_types',
'platforms', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data',
)
class ImageAttachmentForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = ImageAttachment
fields = [
'name', 'image',
]
class JournalEntryForm(BootstrapMixin, forms.ModelForm):
comments = CommentField()
kind = forms.ChoiceField(
choices=add_blank_choice(JournalEntryKindChoices),
required=False,
widget=StaticSelect()
)
class Meta:
model = JournalEntry
fields = ['assigned_object_type', 'assigned_object_id', 'kind', 'comments']
widgets = {
'assigned_object_type': forms.HiddenInput,
'assigned_object_id': forms.HiddenInput,
}

View File

@@ -0,0 +1,30 @@
from django import forms
from utilities.forms import BootstrapMixin
__all__ = (
'ScriptForm',
)
class ScriptForm(BootstrapMixin, forms.Form):
_commit = forms.BooleanField(
required=False,
initial=True,
label="Commit changes",
help_text="Commit changes to the database (uncheck for a dry-run)"
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Move _commit to the end of the form
commit = self.fields.pop('_commit')
self.fields['_commit'] = commit
@property
def requires_input(self):
"""
A boolean indicating whether the form requires user input (ignore the _commit field).
"""
return bool(len(self.fields) > 1)

View File

@@ -1,6 +1,9 @@
import graphene
from django.contrib.contenttypes.models import ContentType
from graphene.types.generic import GenericScalar
from extras.models import ObjectChange
__all__ = (
'ChangelogMixin',
'ConfigContextMixin',
@@ -15,7 +18,12 @@ class ChangelogMixin:
changelog = graphene.List('extras.graphql.types.ObjectChangeType')
def resolve_changelog(self, info):
return self.object_changes.restrict(info.context.user, 'view')
content_type = ContentType.objects.get_for_model(self)
object_changes = ObjectChange.objects.filter(
changed_object_type=content_type,
changed_object_id=self.pk
)
return object_changes.restrict(info.context.user, 'view')
class ConfigContextMixin:

View File

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

View File

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

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