Compare commits

...

286 Commits

Author SHA1 Message Date
Jeremy Stretch
3f5a2b9c7a Merge pull request #5448 from netbox-community/develop
Release v2.9.11
2020-12-11 16:47:30 -05:00
Jeremy Stretch
a589022fcc Merge branch 'master' into develop 2020-12-11 16:46:41 -05:00
Jeremy Stretch
31fc8076e3 Release v2.9.11 2020-12-11 16:33:08 -05:00
John Anderson
53f330dbe8 fixes #5387 - Fix error when rendering config contexts when objects have multiple tags assigned (#5447) 2020-12-11 16:20:46 -05:00
Jeremy Stretch
13a13f3943 Fixes #5446: Fix validation for plugin version and required settings 2020-12-11 16:05:50 -05:00
Jeremy Stretch
00ca227292 Fixes #5433: Exclude SVG files from front/rear image upload for device types (currently unsupported) 2020-12-11 09:27:00 -05:00
Jeremy Stretch
5c8ed73608 Closes #5439: Add CS and SN fiber port types 2020-12-11 08:56:02 -05:00
Jeremy Stretch
5df01ed3e6 Encourage people to use GitHub discussions in place of the mailing list 2020-12-10 15:59:42 -05:00
Jeremy Stretch
e062cbb79f Fixes #5436: Show assigned IP addresses in interfaces list 2020-12-10 08:52:55 -05:00
Jeremy Stretch
27e27788cd Closes #5424: Allow passing Python code to nbshell using --command 2020-12-08 16:14:52 -05:00
Jeremy Stretch
cc5c000a6d Move CI to GitHub Actions (#5431)
Replaces Travis CI with GitHub Actions
2020-12-08 14:55:06 -05:00
Jeremy Stretch
e7f64334c0 Fixes #5410: Restore tags field on cable connection forms 2020-12-04 08:57:19 -05:00
Jeremy Stretch
584b8109a0 Fixes #5408: Fix updating secrets without setting new plaintext 2020-12-03 15:23:39 -05:00
Jeremy Stretch
d75696b30a Fixes #5407: Add direct link to secret on secrets list 2020-12-03 14:15:48 -05:00
Jeremy Stretch
22bb700f94 Fixes #5396: Fix uniqueness constraint for virtual machine names 2020-12-03 13:40:42 -05:00
Jeremy Stretch
a4b2e1f2e2 Fix typo (from PR #5393) 2020-12-03 12:13:08 -05:00
Jeremy Stretch
6ee161bdc6 Add references to GitHub discussions 2020-12-03 12:11:44 -05:00
Hans Erasmus
13349dab6d Fixed small typo (#5393)
Very small typo fixed
2020-12-03 11:48:34 -05:00
Jeremy Stretch
f55e966c8f Closes #4865: Replace all Count() annotations with subqueries (#5385)
* Convert circuits to use subqueries

* Convert dcim to use subqueries

* Convert extras to use subqueries

* Convert ipam to use subqueries

* Convert secrets to use subqueries

* Convert virtualization to use subqueries

* Update global search view to use subqueries where appropriate

* Remove extraneous order_by() calls
2020-11-25 15:49:18 -05:00
Jeremy Stretch
77bbe5730b Fixes #5383: Fix setting user password via REST API 2020-11-25 11:47:53 -05:00
Jeremy Stretch
f8b523b97b Extend assertInstanceEqual to accept a list of data fields to exclude from comparison with the instance 2020-11-25 11:44:44 -05:00
Jeremy Stretch
d250fd91a9 Post-release version bump 2020-11-24 16:48:55 -05:00
Jeremy Stretch
6a90295505 Merge pull request #5377 from netbox-community/develop
Release v2.9.10
2020-11-24 16:47:21 -05:00
Jeremy Stretch
b4c7727a82 Release v2.9.10 2020-11-24 16:36:46 -05:00
Jeremy Stretch
9ac64d57d0 Changelog for #5319 2020-11-24 16:29:09 -05:00
Jeremy Stretch
6f68be5aa7 Merge pull request #5362 from ndom91/5319-usb-power-outlet
Add: USB power outlet/ports
2020-11-24 16:26:26 -05:00
ndom91
bff9bd5dbd chore: fix pep8 formatting fail 2020-11-24 16:07:22 +01:00
ndom91
e7e63796ac fix: typo 2020-11-24 15:50:10 +01:00
ndom91
825cc9a86f update: ordering / completeness of usb connectors 2020-11-24 15:44:51 +01:00
Jeremy Stretch
9923548628 Note that maintenance mode disables last_active updates during login 2020-11-23 11:22:46 -05:00
Jeremy Stretch
181f5c991b Fixes #5309: Avoid extraneous database queries when manipulating objects 2020-11-20 15:40:09 -05:00
Jeremy Stretch
3dd2e4c84c Fixes #5360: Clear VLAN assignments when setting interface mode to none 2020-11-20 14:40:21 -05:00
ndom91
83f7f9f0e5 add: USB power outlet/ports 2020-11-19 23:59:51 +01:00
Jeremy Stretch
58668a6807 Add a "getting started" page for developers 2020-11-18 11:25:31 -05:00
Jeremy Stretch
6d379ad029 Refresh development documentation 2020-11-18 10:07:39 -05:00
Jeremy Stretch
a05fc091c8 Fixes #5356: Populate manufacturer field when adding a device component template 2020-11-17 16:30:58 -05:00
Jeremy Stretch
f2bc6f0f96 Fixes #5355: Limit rack groups by selected site when editing a rack 2020-11-17 16:25:20 -05:00
Jeremy Stretch
8c4d4532a4 Fixes #5235: Fix exception when editing IP address with a NAT IP assigned to a non-racked device 2020-11-16 10:59:41 -05:00
Jeremy Stretch
9a736170fe Fixes #5350: Filter available racks by selected group when creating a rack reservation 2020-11-16 10:38:57 -05:00
Jeremy Stretch
9f8f9e8b97 Fixes #5345: Fix non-deterministic ordering of prefixes and IP addresses 2020-11-16 10:17:05 -05:00
Jeremy Stretch
0ee4bc22e5 Add note about changelog 2020-11-16 09:40:50 -05:00
Jeremy Stretch
b9da0129ff Changelog for #5337 2020-11-16 09:38:00 -05:00
Jeremy Stretch
42c29d0f45 Merge pull request #5342 from JonathonReinhart/5337-add-splice-rearport-type
Closes #5337: Add "splice" port type
2020-11-16 09:34:12 -05:00
Jonathon Reinhart
7aa9ecd17a Closes #5337: Add "splice" port type 2020-11-12 10:41:29 -05:00
Jeremy Stretch
340e665817 Post release version bump 2020-11-09 20:57:35 -05:00
Jeremy Stretch
8ba50d0cf2 Merge pull request #5332 from netbox-community/develop
Release v2.9.9
2020-11-09 20:55:28 -05:00
Jeremy Stretch
a6f0154803 Release v2.9.9 2020-11-09 20:51:35 -05:00
Jeremy Stretch
9b2dc89c6c Closes #5327: Be more strict when capturing anticipated ImportError exceptions 2020-11-09 16:49:05 -05:00
Jeremy Stretch
dc7da4f0f6 Fixes #5316: Dry running scripts should not trigger webhooks 2020-11-09 16:22:45 -05:00
Jeremy Stretch
ab33b3aa84 Fixes #5328: Fix CreatedUpdatedFilterTest when running in non-UTC timezone 2020-11-09 16:04:09 -05:00
Jeremy Stretch
bf836d4d46 Fixes #5331: Fix filtering of sites by null region 2020-11-09 15:58:59 -05:00
Jeremy Stretch
c3f2cc7648 Correct buttons() method in dummy plugin template content 2020-11-09 14:31:53 -05:00
Jeremy Stretch
d2c4c7fb10 Closes #5324: Add missing template extension tags for plugins for VM interface view 2020-11-09 14:19:22 -05:00
Jeremy Stretch
39f4dea051 Merge pull request #5326 from 991jo/patch-1
Fixed a UI Bug where there is no whitespace after grouped custom links.
2020-11-09 12:54:08 -05:00
991jo
7cf4095983 Fixed a UI Bug where there is no whitespace after grouped custom links.
See #5325 for details
2020-11-09 07:56:26 +01:00
John Anderson
0d27abc6fc fixes #5314 - Fix config context rendering when multiple tags are assgined to an object 2020-11-06 16:47:07 -05:00
Jeremy Stretch
b39019cec9 Clarify cache invalidation instructions 2020-11-05 14:41:06 -05:00
Jeremy Stretch
f2cba78549 Closes #5310: Link to rack groups within rack list table 2020-11-05 14:24:22 -05:00
Jeremy Stretch
f845eeb117 Closes #5304: Return server error messages as JSON when handling REST API requests 2020-11-04 17:11:57 -05:00
Jeremy Stretch
c53990c739 Closes #5307: Add a region selector for every site form field 2020-11-04 15:27:41 -05:00
Jeremy Stretch
7d52b4d9a5 Changelog for #5271 2020-11-04 13:23:56 -05:00
Jeremy Stretch
a69d61c124 Merge pull request #5308 from netbox-community/5271-DynamicModelChoiceMixin-initial
Fixes #5271: Extend DynamicModelChoiceMixin to accept initial field values implied by child fields
2020-11-04 13:20:30 -05:00
Jeremy Stretch
798220cf9a Fix filter reference 2020-11-04 13:05:24 -05:00
Jeremy Stretch
7598b891c1 Add initial_params to all forms 2020-11-04 11:09:13 -05:00
Jeremy Stretch
1f0b1dd10e Add initial_params to DynamicModelChoiceMixin 2020-11-04 10:15:15 -05:00
Jeremy Stretch
f0bac741cc Move to lock-threads app 2020-11-04 08:58:20 -05:00
Jeremy Stretch
2424f684b9 Post-release version bump 2020-10-30 10:41:05 -04:00
Jeremy Stretch
6dcb2de28f Merge pull request #5293 from netbox-community/develop
Release v2.9.8
2020-10-30 10:38:17 -04:00
Jeremy Stretch
cbd155406b Release v2.9.8 2020-10-30 10:11:13 -04:00
Jeremy Stretch
70cc368225 Changelog for #4559, #5282, #5285 2020-10-30 09:14:52 -04:00
Jeremy Stretch
6d28eae5dc Merge pull request #5290 from nniehoff/5282-ldap-logging
Fixing #5282 - moving logging configuration from to configuration.py
2020-10-30 09:11:12 -04:00
Jeremy Stretch
ca932ecadc Merge pull request #5286 from netbox-community/5285-plugins-api-app_name
Fixes #5285: Include plugin API endpoints in root view without needing app_name set
2020-10-30 09:10:12 -04:00
Jeremy Stretch
04d763d814 Merge pull request #5266 from netbox-community/4559-config-context-rendering
4559 config context rendering
2020-10-30 09:09:40 -04:00
Nick Niehoff
97ea15e978 Removing logfile variable 2020-10-30 06:32:12 -06:00
John Anderson
28c17f33ab move get_queryset() to common mixin 2020-10-30 02:56:43 -04:00
John Anderson
db87a69488 convert region fields to f-string 2020-10-30 02:56:26 -04:00
Nick Niehoff
2b154dcfdf Fixing #5282 - moving logging configuration from authentication.py to configuration.py 2020-10-29 15:14:52 -06:00
Jeremy Stretch
1000039c74 Merge pull request #5277 from systeembeheerder/develop
Update upgrading.md
2020-10-29 17:11:04 -04:00
Jeremy Stretch
2ccc543542 Document the available template blocks within base.html 2020-10-29 17:10:19 -04:00
systeembeheerder
8ac2ee7b49 Update upgrading.md
make destination path for cp media absolute
2020-10-29 22:05:19 +01:00
Jeremy Stretch
db3fd27da2 Merge pull request #5283 from markkuleinio/develop
Fix #5265: Change systemctl status outputs to venv era
2020-10-29 16:49:34 -04:00
Jeremy Stretch
b871d2a8c4 Fixes #5285: Include plugin API endpoints in root view without needing app_name set 2020-10-29 12:30:10 -04:00
Markku Leinio
02f348b15f Fix typo in weekday 2020-10-28 21:06:20 +02:00
Markku Leinio
4672f16fda Fix #5265: Change systemctl status outputs to venv era 2020-10-28 21:03:35 +02:00
systeembeheerder
6404657f99 Update upgrading.md
confirm 5275; use absolute paths
2020-10-27 16:07:38 +01:00
Jeremy Stretch
2f3557c18f Fixes #5273: Fix exception when validating a new permission with no models selected 2020-10-27 10:01:49 -04:00
John Anderson
047f03a58c clean up imports 2020-10-25 19:00:56 -04:00
John Anderson
606b015314 Merge branch 'develop' into 4559-config-context-rendering 2020-10-25 18:47:40 -04:00
John Anderson
478d82196a rebase develop 2020-10-25 18:20:21 -04:00
John Anderson
034ff2fb3f #3672 changelog 2020-10-25 18:19:20 -04:00
John Anderson
9bfb89fffd fixes #3672 - update django-cacheops to 5.1.0 2020-10-25 18:19:20 -04:00
John Anderson
26ff33c41a pep8 2020-10-25 17:56:42 -04:00
John Anderson
9e84e3b83b added tests 2020-10-25 16:49:18 -04:00
John Anderson
82f5d0070e account for null value annotations 2020-10-23 10:56:02 -04:00
John Anderson
22d2289ed2 add support for regions and vms 2020-10-23 01:18:04 -04:00
John Anderson
3ba18633de initial work on config context performance improvements 2020-10-20 01:07:22 -04:00
Jeremy Stretch
dde52309d2 Fixes #5243: Redirect user to appropriate tab after modifying device components 2020-10-15 16:55:20 -04:00
John Anderson
87c13a876b fixes #5113 - incorrect caching of permission object assignments to user groups in the admin panel 2020-10-14 20:10:21 -04:00
Jeremy Stretch
0d01a3fce8 Add schema migrations to plugins installation doc 2020-10-14 10:42:31 -04:00
Jeremy Stretch
d4b96a5a27 Post-release version bump 2020-10-12 09:51:11 -04:00
Jeremy Stretch
1bbfc6da25 Merge pull request #5236 from netbox-community/develop
Release v2.9.7
2020-10-12 09:49:51 -04:00
Jeremy Stretch
1aabb2ed5d Release v2.9.7 2020-10-12 09:39:39 -04:00
Jeremy Stretch
f53810ebb2 Fixes #5231: Fix KeyError exception when viewing object with custom link and debugging is disabled 2020-10-09 15:53:45 -04:00
Jeremy Stretch
7545599493 Post-release version bump 2020-10-09 13:31:28 -04:00
Jeremy Stretch
eecd51e08b Merge pull request #5230 from netbox-community/develop
Release v2.9.6
2020-10-09 13:27:42 -04:00
Jeremy Stretch
3047208e6a Release v2.9.6 2020-10-09 13:17:16 -04:00
Jeremy Stretch
a796e29c98 Fixes #5229: Fix AttributeError exception when LDAP authentication is enabled 2020-10-09 13:16:07 -04:00
Jeremy Stretch
26abd1f4e2 Merge pull request #5228 from netbox-community/develop
Release v2.9.5
2020-10-09 13:01:24 -04:00
Jeremy Stretch
5f0e04aebe Post-release version bump 2020-10-09 13:01:01 -04:00
Jeremy Stretch
918ca4fdcb Release v2.9.5 2020-10-09 12:57:14 -04:00
Jeremy Stretch
24e5dd0c78 Add scope warning to plugins development doc 2020-10-09 12:56:32 -04:00
Jeremy Stretch
f35715683e Closes #5202: Extend the available context data when rendering custom links 2020-10-09 12:36:09 -04:00
Jeremy Stretch
b5a65bc66c Fixes #5211: Add missing has_primary_ip filter for virtual machines 2020-10-09 11:46:16 -04:00
Jeremy Stretch
3a21f7ab26 Fixes #5226: Custom choice fields should be blank initially if no default choice has been designated 2020-10-09 11:34:50 -04:00
Jeremy Stretch
a2c012d2c4 Fixes #5224: Don't allow a rear port to have fewer positions than the number of mapped front ports 2020-10-08 16:24:08 -04:00
Daniel Sheppard
4896059162 Fixes: #5220 - Fix API patch request against IP-Address endpoint with null assigned_object_type 2020-10-08 12:50:29 -05:00
Daniel Sheppard
eadecf3eda Fixes: #5221 - Fix bulk component creation over multiple VMs 2020-10-08 12:39:20 -05:00
Jeremy Stretch
39e4ab164e Fixes #5218: Raise validation error if a power port's allocated_draw exceeds its maximum_draw 2020-10-07 13:23:19 -04:00
Jeremy Stretch
5089df3ffd Fix error in OBJECTCHANGE_OBJECT table column template 2020-10-07 13:13:03 -04:00
Jeremy Stretch
9738257aa2 Fixes #5217: Prevent erroneous removal of prefetched GenericForeignKey data from tables 2020-10-07 13:09:08 -04:00
Jeremy Stretch
693b32077a Update the netbox-discuss mailing list URL 2020-10-07 08:53:04 -04:00
Jeremy Stretch
47ea75348c Changelog for #5197 2020-10-02 14:17:55 -04:00
Jeremy Stretch
cd9c425d9a Merge pull request #5208 from glennmatthews/gfm-issue-5197
5197: Limit main IPAddress view to a max of 10 duplicate addresses; add new duplicates view
2020-10-02 14:16:32 -04:00
Glenn Matthews
6612e0107e Limit main IPAddress view to a max of 10 duplicate addresses. Fixes #5197 2020-10-02 14:01:51 -04:00
Jeremy Stretch
ae0a481c64 Fixes #5206: Apply user pagination preferences to all paginated object lists 2020-10-02 13:54:57 -04:00
Daniel Sheppard
1a43ef82d9 Fixes: #5201 - Fix bulk "all" operations on VLAN Groups 2020-10-02 09:59:16 -05:00
Daniel Sheppard
f2bc824a8e Fixes: #5199 - Change default logging for LDAP to INFO 2020-10-01 09:27:17 -05:00
Daniel Sheppard
23a9cb2fad Fixes: #5199 - Change default logging for LDAP to INFO 2020-10-01 09:25:56 -05:00
Daniel Sheppard
22a80c5558 Update pre-commit script to support virtual environment within IDE 2020-09-30 08:28:48 -05:00
Daniel Sheppard
14ef7fa869 Update version-2.9.md 2020-09-29 19:55:59 -05:00
Daniel Sheppard
068803dc86 Update 2.9 Release notes with 4523 2020-09-29 19:55:40 -05:00
Daniel Sheppard
ee7f43abe1 Fixes #4523 - Add site vlan to certain scenarios where sites are the same 2020-09-29 14:45:02 -05:00
Jeremy Stretch
015e25c618 Merge pull request #5185 from kobayashi/5184-power-util
Fixes #5184: Fix missing Power Utilization
2020-09-28 09:17:49 -04:00
kobayashi
5c8694ad65 Fixes #5184: Fix missing Power Utilization 2020-09-27 16:33:47 -04:00
Jeremy Stretch
ff42c89cc4 Closes #5177: Note permissions required for running reports and custom scripts 2020-09-24 10:32:15 -04:00
Jeremy Stretch
856500b014 Fixes #5174: Ensure consistent alignment of rack elevations 2020-09-24 10:11:34 -04:00
Jeremy Stretch
380f59ac0b Fixes #5175: Fix toggling of rack elevation order 2020-09-24 10:01:02 -04:00
Jeremy Stretch
d30874e0de Post-release version bump 2020-09-23 15:39:36 -04:00
Jeremy Stretch
28925c12eb Merge pull request #5172 from netbox-community/develop
Release v2.9.4
2020-09-23 15:38:09 -04:00
Jeremy Stretch
31fcad4dbb Release v2.9.4 2020-09-23 15:24:59 -04:00
Jeremy Stretch
e2a840ff0b Closes #5053: Mention local_requirements.txt when an ImportError occurs 2020-09-23 15:19:42 -04:00
Jeremy Stretch
0a40418614 Fixes #5066: Update view_reportresult to view_report permission 2020-09-23 13:40:15 -04:00
Jeremy Stretch
90dbe9bf60 Closes #5171: Introduce the RQ_DEFAULT_TIMEOUT configuration parameter 2020-09-23 13:28:05 -04:00
Jeremy Stretch
09dc271eec Fix reference to update_data 2020-09-23 12:20:45 -04:00
Jeremy Stretch
1f0a4cc548 Closes #5075: Include a VLAN membership view for VM interfaces 2020-09-23 11:48:32 -04:00
Jeremy Stretch
c0b94e4e8e Fixes #5137: Correct permission for viewing report results is extras.view_reportresult 2020-09-23 10:16:22 -04:00
Jeremy Stretch
e404f4efd2 Closes #5164: Show total rack count per rack group under site view 2020-09-23 09:46:52 -04:00
Jeremy Stretch
afa1449f89 Fixes #5167: Support filtering ObjectChanges by multiple users 2020-09-22 16:17:39 -04:00
Jeremy Stretch
d540728f50 Closes #5149: Add rack group field to device edit form 2020-09-21 15:26:32 -04:00
Jeremy Stretch
12402f4c30 Fixes #5156: Add missing "add" button to rack reservations list 2020-09-21 15:14:44 -04:00
Jeremy Stretch
2bc524a2ee Standardize usage of BooleanColumn 2020-09-16 14:25:07 -04:00
Jeremy Stretch
43f1fbf5b3 Fixes #5136: Fix exception when bulk editing interface 802.1Q mode 2020-09-16 13:07:55 -04:00
Jeremy Stretch
e983f44fd3 Closes #5128: Increase maximum rear port positions from 64 to 1024 2020-09-16 12:53:11 -04:00
Jeremy Stretch
4d9da4a1f8 Closes #5134: Display full hierarchy in breadcrumbs for sites/racks 2020-09-16 12:44:49 -04:00
Jeremy Stretch
9d30712fb2 Changelog for #5133 (fixed in #5105) 2020-09-16 11:20:00 -04:00
Jeremy Stretch
03b207d154 Fixes #5105: Validation should fail when reassigning a primary IP from device to VM 2020-09-16 11:10:30 -04:00
Jeremy Stretch
df6ad680ce Closes #1755: Toggle order in which rack elevations are displayed 2020-09-14 14:22:21 -04:00
Jeremy Stretch
b1b63513e7 Changelog for #5108 2020-09-14 10:41:04 -04:00
Jeremy Stretch
22e30b93d5 Merge pull request #5126 from netbox-community/5108-report-management-command-fix
fixes #5108 - correct the runreport management command to work with JobResult model
2020-09-14 10:39:33 -04:00
John Anderson
c5e82a3895 fixes #5108 - correct the runreport management command to work with JobResults model 2020-09-10 17:43:41 -04:00
Jeremy Stretch
4466458076 Fixes #5118: Specifying an empty list of tags should clear assigned tags (REST API) 2020-09-09 13:43:10 -04:00
Jeremy Stretch
47a6fc19ca Fixes #5109: Fix representation of custom choice field values for webhook data 2020-09-09 11:47:21 -04:00
Jeremy Stretch
c891f43b14 Fixes #5050: Fix potential failure on 0016_replicate_interfaces schema migration from old release 2020-09-09 10:29:17 -04:00
Jeremy Stretch
1509650462 Closes #5107: Add note about dropping backward compatibility for old REDIS configuration format 2020-09-09 10:00:50 -04:00
Jeremy Stretch
695e9ec5d7 Fixes #5111: Allow use of tuples when specifying ObjectVar query_params 2020-09-09 09:52:57 -04:00
Jeremy Stretch
0c8d45f679 Post-release version bump 2020-09-04 15:57:28 -04:00
Jeremy Stretch
d0ac4332ab Merge pull request #5100 from netbox-community/develop
Release v2.9.3
2020-09-04 15:55:47 -04:00
Jeremy Stretch
08b9eedcec Correct changelog for #5095 2020-09-04 15:46:39 -04:00
Jeremy Stretch
5f9e687c9c Release v2.9.3 2020-09-04 15:45:23 -04:00
Jeremy Stretch
f1877fcea9 Closes #5048: Show the device/VM name when editing a component 2020-09-04 15:10:34 -04:00
Jeremy Stretch
78d104e60c Fixes #5046: Disabled plugin menu items are no longer clickable 2020-09-04 14:13:35 -04:00
Jeremy Stretch
dd0185816c Changelog for #4977, #5095 2020-09-04 13:57:29 -04:00
Jeremy Stretch
83eede8bc5 Merge pull request #5071 from n0emis/redirect-when-logged-in
Redirect users away from /login, if they are already logged in
2020-09-04 13:53:51 -04:00
Jeremy Stretch
b22d4cb9ca Merge pull request #5097 from jeremystretch/5095-vlan-prefixes
Fixes #5095: Fix display of assigned prefixes in VLANs list
2020-09-03 11:47:03 -04:00
Jeremy Stretch
de081d0205 Fixes #5095: Fix display of assigned prefixes in VLANs list 2020-09-03 11:28:25 -04:00
Simeon Keske
f92569d468 move redirect code to own function 2020-09-02 22:33:39 +02:00
Jeremy Stretch
bbbfc27593 Fixes #5072: Add REST API filters for image attachments 2020-09-02 16:10:07 -04:00
Jeremy Stretch
d289b26034 Fixes #5089: Redirect to device view after editing component 2020-09-02 13:45:15 -04:00
Jeremy Stretch
babdc1db38 Link to console/power ports in connection views 2020-09-02 13:33:59 -04:00
Jeremy Stretch
b559c827d2 Fixes #5091: Avoid KeyError when handling invalid table preferences 2020-09-02 13:30:19 -04:00
Jeremy Stretch
268aa755c4 Closes #5080: Add 8P6C, 8P4C, 8P2C port types 2020-09-02 10:21:22 -04:00
Jeremy Stretch
5d7935c855 Fixes #5090: Fix status display for console/power/interface connections 2020-09-01 16:28:11 -04:00
Jeremy Stretch
6305a35a4f Fixes #5085: Fix ordering by assignment in IP addresses table 2020-09-01 15:18:47 -04:00
Jeremy Stretch
a0103036e4 Fixes #5087: Restore label field when editing console server ports, power ports, and power outlets 2020-09-01 13:59:50 -04:00
Jeremy Stretch
b3b7e08c50 Closes #5076: Specify pip3 2020-09-01 10:24:17 -04:00
Jeremy Stretch
7dbeaf7a01 Fixes #5078: Fix assignment of existing IP addresses to interfaces via web UI 2020-09-01 10:19:28 -04:00
Jeremy Stretch
967073eaaf Fixes #5081: Fix exception during webhook processing with custom select field 2020-09-01 09:54:45 -04:00
Jeremy Stretch
473d76c9d1 Fixes #5074: Fix inclusion of VC member interfaces when viewing VC master 2020-08-31 15:34:40 -04:00
Jeremy Stretch
5bff50cade Fixes #5063: Fix "add device" link in rack elevations for opposite side of half-depth devices 2020-08-31 15:26:00 -04:00
Simeon Keske
bfcbd9da6c Redirect users away from /login, if they are already logged in 2020-08-30 01:44:27 +02:00
Jeremy Stretch
2435c177f5 Post-release version bump 2020-08-27 14:15:09 -04:00
Jeremy Stretch
8e5aa69321 Merge pull request #5062 from netbox-community/develop
Release v2.9.2
2020-08-27 14:13:58 -04:00
Jeremy Stretch
f3e4911c68 Release v2.9.2 2020-08-27 14:03:51 -04:00
Jeremy Stretch
e8e4ff4111 Closes #5056: Add interface and parent columns to IP address list 2020-08-27 13:46:31 -04:00
Jeremy Stretch
523c32b8af Fixes #5061: Allow adding/removing tags when bulk editing virtual machine interfaces 2020-08-27 13:26:41 -04:00
Jeremy Stretch
5cdccb47f4 Fixes #5060: Fix validation when bulk-importing child devices 2020-08-27 11:27:17 -04:00
Jeremy Stretch
fa73bf8e87 Closes #5505: Add tags column to device/VM component list tables 2020-08-27 09:43:20 -04:00
Jeremy Stretch
5fe4e6cc96 Fixes #5058: Correct URL for front rack elevation images when using external storage 2020-08-27 09:26:56 -04:00
Jeremy Stretch
f23900fc8c Fixes #5059: Fix inclusion of checkboxes for interfaces in virtual machine view 2020-08-27 09:22:53 -04:00
Jeremy Stretch
a0790e9119 Changelog for #5002 2020-08-24 15:17:36 -04:00
Jeremy Stretch
236db7d42d Merge pull request #5039 from innovationnorway/5002-available-prefixes-swagger
Use correct serializer for available-prefixes POST response
2020-08-24 15:09:58 -04:00
Jeremy Stretch
5da7590eea Fixes #4988: Fix ordering of rack reservations with identical creation times 2020-08-24 12:04:48 -04:00
Jeremy Stretch
df97eb2f72 Fixes #5045: Allow assignment of interfaces to non-master VC peer LAG during import 2020-08-24 11:33:45 -04:00
Jeremy Stretch
32a0e519ad Fixes #5041: Fix form tabs when assigning an IP to a VM interface 2020-08-24 10:56:23 -04:00
Jeremy Stretch
78d6561e39 Fixes #5040: Limit SLAAC status to IPv6 addresses 2020-08-24 10:51:47 -04:00
Jeremy Stretch
9147823305 Fixes #5042: Fix display of SLAAC label for IP addresses status 2020-08-24 10:47:26 -04:00
Jeremy Stretch
e7cf87be97 Fixes #5035: Fix exception when modifying an IP address assigned to a VM 2020-08-24 10:39:41 -04:00
Jeremy Stretch
6e28490b84 Fixes #5038: Fix validation of primary IPs assigned to virtual machines 2020-08-24 09:41:04 -04:00
Joakim Bakke Hellum
fcc15d2e33 Use correct serializer for available-prefixes POST response
POST `/ipam/prefixes/{id}/available-prefixes/` returns single `Prefix` object, not list of `AvailablePrefix` objects.
2020-08-23 20:49:50 +02:00
Jeremy Stretch
3522eafd2c Post-release version bump 2020-08-22 21:06:06 -04:00
Jeremy Stretch
848cfeb353 Merge pull request #5034 from netbox-community/develop
Release v2.9.1 - 2020-08-22
2020-08-22 21:05:08 -04:00
Jeremy Stretch
35a280eb31 Release v2.9.1 2020-08-22 21:03:51 -04:00
Jeremy Stretch
aedba0e8be Closes #5030: Call out required minimum versions for depdencies in upgrade documentation 2020-08-22 20:53:21 -04:00
Jeremy Stretch
728088f5fa Closes #5033: Support backward compatibility for REMOTE_AUTH_BACKEND 2020-08-22 20:39:46 -04:00
Jeremy Stretch
2116b928b6 Add link to v2.9 release notes 2020-08-21 16:44:13 -04:00
Jeremy Stretch
f37997ac54 Closes #4814: Allow nested LAG interfaces 2020-08-21 13:35:03 -04:00
Jeremy Stretch
ed65603632 Closes #4540: Add IP address status type for SLAAC 2020-08-21 13:17:41 -04:00
Jeremy Stretch
802af06c0f Closes #4991: Add Python and NetBox versions to error page 2020-08-21 12:58:48 -04:00
Jeremy Stretch
e02590ac96 Post-release version bump 2020-08-21 09:56:29 -04:00
Jeremy Stretch
7b05a18173 Merge pull request #5026 from netbox-community/develop
Release v2.9.0
2020-08-21 09:52:01 -04:00
Jeremy Stretch
b22995a6d7 Snip beta warning 2020-08-21 09:41:28 -04:00
Jeremy Stretch
6c1436174c Release v2.9.0 2020-08-21 09:39:31 -04:00
Jeremy Stretch
b2aa9b82c8 Merge pull request #5000 from netbox-community/develop-2.9
Stage v2.9 release
2020-08-21 09:32:00 -04:00
Jeremy Stretch
23aae52992 Merge branch 'develop' into develop-2.9 2020-08-21 09:21:18 -04:00
Jeremy Stretch
8b5e701ba4 Cleaned up release notes for v2.9.0 2020-08-21 09:18:28 -04:00
Jeremy Stretch
8f88d2afab Closes #5024: List available options for choice fields within CSV import forms 2020-08-20 15:44:30 -04:00
Jeremy Stretch
8d351178ac Fixes #5022: Fix exception when editing IP address with NAT inside IP assigned 2020-08-20 14:38:58 -04:00
Jeremy Stretch
bc0e6cc8dd Fixes #5012: Return details of exceptions resulting from report/script execution 2020-08-20 12:47:26 -04:00
Jeremy Stretch
bf4fee1592 Fixes #5020: Correct handling of dependent objects during bulk deletion 2020-08-20 09:44:45 -04:00
Jeremy Stretch
e1cf27a3ac Refactor DCIM models into separate submodules 2020-08-19 16:37:23 -04:00
Jeremy Stretch
db5bb8e5bb Fix changelog tests 2020-08-19 16:02:10 -04:00
Jeremy Stretch
3ebef04a11 Closes #5016: assertHttpStatus() should report form validation errors 2020-08-18 17:02:47 -04:00
Jeremy Stretch
0d9fc309d5 Update test script ObjectVars 2020-08-18 14:43:21 -04:00
Jeremy Stretch
c9c79dabef Fixes #5004: Permit assignment of an interface to a LAG on any peer virtual chassis member 2020-08-18 14:41:47 -04:00
Jeremy Stretch
5fad6a63ca Merge pull request #5009 from kobayashi/4989-vmcount
Fixes #4989: Fix no vm count for cluster in global search
2020-08-18 14:03:57 -04:00
Jeremy Stretch
82900fb65d Changelog for #4990 2020-08-18 14:01:59 -04:00
Jeremy Stretch
046272ff37 Merge pull request #5010 from netbox-community/4990-custom-script-changelog
Fixes #4990: Object change logging during custom script execution
2020-08-18 13:57:21 -04:00
Jeremy Stretch
bc5f800a8b Refactor tests (again) 2020-08-18 13:30:16 -04:00
Jeremy Stretch
36d86e6220 Avoid using post_data() for form data 2020-08-18 13:13:13 -04:00
Jeremy Stretch
986ef2b8e6 Move changelog signals setup to a context manager 2020-08-18 13:05:41 -04:00
Jeremy Stretch
5629124755 Tweak passing of tags 2020-08-18 12:37:07 -04:00
Jeremy Stretch
0bfb64dc09 Simplify tag creation 2020-08-18 11:33:23 -04:00
Jeremy Stretch
c482dcd8cb Dump full response content on unexpected status code 2020-08-18 10:41:28 -04:00
Jeremy Stretch
881cab051b Add changelog tests for UI views 2020-08-18 10:05:58 -04:00
Jeremy Stretch
afebf525d1 Move housekeeping to _handle_changed_object() 2020-08-18 09:13:07 -04:00
Jeremy Stretch
0e5d0a43f9 Fix serialization of tags for REST API updates 2020-08-17 16:18:47 -04:00
Jeremy Stretch
cf086cd7b2 Remove errant import 2020-08-17 14:15:02 -04:00
Jeremy Stretch
81c72739b5 Attach object modification signals before running a custom script 2020-08-17 13:43:48 -04:00
Jeremy Stretch
ff5a3c1055 Cache custom fields on instance during bulk edit 2020-08-17 12:29:40 -04:00
Jeremy Stretch
bc04543b33 Cache custom fields on instance prior to calling create()/update() 2020-08-17 12:22:37 -04:00
Jeremy Stretch
dd707c97af Cache custom fields on instance prior to save() 2020-08-17 11:08:14 -04:00
kobayashi
34708a8fa5 Fixes #4989: Fix no vm count for cluster in global search 2020-08-17 10:59:01 -04:00
Jeremy Stretch
4ee8e473eb Move ObjectChange creation into signal receivers 2020-08-14 17:03:45 -04:00
Jeremy Stretch
b4299241fe Cast all query param values to string 2020-08-14 11:38:15 -04:00
Jeremy Stretch
66c91484f5 Clean up display_name for various models 2020-08-14 10:20:34 -04:00
Jeremy Stretch
808d621eda VC member selection should use display_name 2020-08-14 10:09:54 -04:00
Jeremy Stretch
943c2230ba Bump version for upcoming v2.9 release 2020-08-13 13:12:09 -04:00
Jeremy Stretch
2ce99929e2 Merge pull request #4947 from netbox-community/develop
v2.8.9 - 2020-08-04
2020-08-04 12:39:55 -04:00
Jeremy Stretch
f1e82a3647 Merge pull request #4873 from netbox-community/develop
Release v2.8.8
2020-07-21 12:21:04 -04:00
Jeremy Stretch
1c5af01a82 Merge pull request #4816 from netbox-community/develop
Release v2.8.7
2020-07-02 09:42:41 -04:00
Jeremy Stretch
bac3ace8fc Merge pull request #4762 from netbox-community/develop
Release v2.8.6
2020-06-15 14:45:01 -04:00
Jeremy Stretch
68599351aa Merge pull request #4693 from netbox-community/develop
Release v2.8.5
2020-05-26 16:27:36 -04:00
Jeremy Stretch
86755029ef Merge pull request #4642 from netbox-community/develop
Release v2.8.4
2020-05-13 17:31:12 -04:00
Jeremy Stretch
c507ab30e9 Merge pull request #4594 from netbox-community/develop
Release v2.8.3
2020-05-06 23:49:27 -04:00
Jeremy Stretch
7d1614b933 Merge pull request #4589 from netbox-community/develop
Release v2.8.2
2020-05-06 15:14:45 -04:00
Jeremy Stretch
a77d1e502c Merge pull request #4528 from netbox-community/develop
Release v2.8.1
2020-04-23 10:24:08 -04:00
Jeremy Stretch
d79ed76d80 Merge pull request #4479 from netbox-community/develop
Release v2.8.0
2020-04-13 11:29:33 -04:00
Jeremy Stretch
ccf8059452 Merge pull request #4467 from netbox-community/develop
Release v2.7.12
2020-04-08 13:31:49 -04:00
Jeremy Stretch
3d3d1bc623 Merge pull request #4417 from netbox-community/develop
Release v2.7.11
2020-03-27 12:47:28 -04:00
Jeremy Stretch
e66d065b6d Merge pull request #4339 from netbox-community/develop
Release v2.7.10
2020-03-10 13:59:50 -04:00
Jeremy Stretch
c1ef87e009 Merge pull request #4321 from netbox-community/develop
Release v2.7.9
2020-03-06 11:23:42 -05:00
Jeremy Stretch
3c249a40a0 Merge pull request #4275 from netbox-community/develop
Release v2.7.8
2020-02-25 15:09:41 -05:00
Jeremy Stretch
5092641157 Merge pull request #4216 from netbox-community/develop
Release v2.7.7
2020-02-20 14:56:27 -05:00
Jeremy Stretch
472a45ddec Merge pull request #4167 from netbox-community/develop
Release v2.7.6
2020-02-13 21:50:44 -05:00
Jeremy Stretch
120cbb0159 Merge pull request #4165 from netbox-community/develop
Release v2.7.5
2020-02-13 15:36:50 -05:00
Jeremy Stretch
68fbd9b017 Merge pull request #4088 from netbox-community/develop
Release v2.7.4
2020-02-04 15:04:34 -05:00
Jeremy Stretch
3143f75a38 Merge pull request #4035 from netbox-community/develop
Release v2.7.3
2020-01-28 16:39:09 -05:00
Jeremy Stretch
e13d4ffe60 Merge pull request #3980 from netbox-community/develop
Release v2.7.2
2020-01-21 15:12:00 -05:00
Jeremy Stretch
295d4f0394 Merge pull request #3946 from netbox-community/develop
Release v2.7.1
2020-01-16 23:46:40 -05:00
Jeremy Stretch
ea91e09a1b Merge pull request #3938 from netbox-community/develop
Release v2.7.0
2020-01-16 13:03:42 -05:00
Jeremy Stretch
946779000f Merge pull request #3908 from netbox-community/develop
Release v2.6.12
2020-01-13 13:25:21 -05:00
Jeremy Stretch
5c07b6dc1d Merge pull request #3837 from netbox-community/develop
Release v2.6.11
2020-01-03 14:00:10 -05:00
Jeremy Stretch
25c3c1b431 Merge pull request #3829 from netbox-community/develop
Fix v2.6.10 release date
2020-01-02 17:05:21 -05:00
Jeremy Stretch
a0ae7a227d Merge pull request #3828 from netbox-community/develop
Release v2.6.10
2020-01-02 17:02:52 -05:00
Jeremy Stretch
50df3acd26 Merge pull request #3774 from netbox-community/develop
Release v2.6.9
2019-12-16 16:32:00 -05:00
Jeremy Stretch
425670f52a Merge pull request #3745 from netbox-community/develop
Release v2.6.8
2019-12-10 10:47:48 -05:00
180 changed files with 5803 additions and 3697 deletions

View File

@@ -7,11 +7,9 @@ about: Report a reproducible bug in the current release of NetBox
<!--
NOTE: IF YOUR ISSUE DOES NOT FOLLOW THIS TEMPLATE, IT WILL BE CLOSED.
This form is only for reproducible bugs. If you need assistance with
NetBox installation, or if you have a general question, DO NOT open an
issue. Instead, post to our mailing list:
https://groups.google.com/forum/#!forum/netbox-discuss
This form is only for reporting reproducible bugs. If you need assistance
with NetBox installation, or if you have a general question, please start a
discussion instead: https://github.com/netbox-community/netbox/discussions
Please describe the environment in which you are running NetBox. Be sure
that you are running an unmodified instance of the latest stable release

View File

@@ -5,5 +5,5 @@ contact_links:
url: https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md
about: Please read through our contributing policy before opening an issue or pull request
- name: 💬 Discussion Group
url: https://groups.google.com/forum/#!forum/netbox-discuss
url: https://groups.google.com/g/netbox-discuss
about: Join our discussion group for assistance with installation issues and other problems

View File

@@ -8,10 +8,8 @@ about: Propose a new NetBox feature or enhancement
NOTE: IF YOUR ISSUE DOES NOT FOLLOW THIS TEMPLATE, IT WILL BE CLOSED.
This form is only for proposing specific new features or enhancements.
If you have a general idea or question, please post to our mailing list
instead of opening an issue:
https://groups.google.com/forum/#!forum/netbox-discuss
If you have a general idea or question, please start a discussion instead:
https://github.com/netbox-community/netbox/discussions
NOTE: Due to an excessive backlog of feature requests, we are not currently
accepting any proposals which significantly extend NetBox's feature scope.
@@ -21,8 +19,8 @@ about: Propose a new NetBox feature or enhancement
before submitting a bug report.
-->
### Environment
* Python version: <!-- Example: 3.6.9 -->
* NetBox version: <!-- Example: 2.7.3 -->
* Python version:
* NetBox version:
<!--
Describe in detail the new functionality you are proposing. Include any

23
.github/lock.yml vendored
View File

@@ -1,23 +0,0 @@
# Configuration for Lock (https://github.com/apps/lock)
# Number of days of inactivity before a closed issue or pull request is locked
daysUntilLock: 90
# Skip issues and pull requests created before a given timestamp. Timestamp must
# follow ISO 8601 (`YYYY-MM-DD`). Set to `false` to disable
skipCreatedBefore: false
# Issues and pull requests with these labels will be ignored. Set to `[]` to disable
exemptLabels: []
# Label to add before locking, such as `outdated`. Set to `false` to disable
lockLabel: false
# Comment to post before locking. Set to `false` to disable
lockComment: false
# Assign `resolved` as the reason for locking. Set to `false` to disable
setLockReason: true
# Limit to only `issues` or `pulls`
# only: issues

50
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,50 @@
name: CI
on: push
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.6, 3.7]
services:
redis:
image: redis
ports:
- 6379:6379
postgres:
image: postgres
env:
POSTGRES_USER: netbox
POSTGRES_PASSWORD: netbox
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- name: Check out repo
uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies & set up configuration
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pycodestyle coverage
ln -s configuration.testing.py netbox/netbox/configuration.py
- name: Check PEP8 compliance
run: pycodestyle --ignore=W504,E501 netbox/
- name: Run tests
run: coverage run --source="netbox/" netbox/manage.py test netbox/
- name: Show coverage report
run: coverage report --skip-covered --omit *migrations*

21
.github/workflows/lock.yml vendored Normal file
View File

@@ -0,0 +1,21 @@
# lock-threads (https://github.com/marketplace/actions/lock-threads)
name: 'Lock threads'
on:
schedule:
- cron: '0 3 * * *'
jobs:
lock:
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@v2
with:
github-token: ${{ github.token }}
issue-lock-inactive-days: '90'
issue-exclude-created-before: ''
issue-exclude-labels: ''
issue-lock-labels: ''
issue-lock-comment: ''
issue-lock-reason: 'resolved'
process-only: 'issues'

View File

@@ -1,19 +0,0 @@
sudo: required
services:
- postgresql
- redis-server
addons:
postgresql: "9.6"
language: python
python:
- "3.6"
- "3.7"
install:
- pip install -r requirements.txt
- pip install pycodestyle
- pip install coverage
before_script:
- psql --version
- psql -U postgres -c 'SELECT version();'
script:
- ./scripts/cibuild.sh

View File

@@ -4,15 +4,33 @@ If you encounter any issues installing or using NetBox, try one of the
following resources to get assistance. Please **do not** open a GitHub issue
except to report bugs or request features.
### GitHub Discussions
GitHub's discussions are the best place to get help or propose rough ideas for
new functionality. Their integration with GitHub allows for easily cross-
referencing and converting posts to issues as needed. There are several
categories for discussions:
* **General** - General community discussion
* **Ideas** - Ideas for new functionality that isn't yet ready for a formal
feature request
* **Q&A** - Request help with installing or using NetBox
* **Show and tell** - Share a plugin, script, or something else you've made
using NetBox
### Mailing List
We have established a Google Groups Mailing List for issues and general
discussion. This is the best forum for obtaining assistance with NetBox
installation. You can find us [here](https://groups.google.com/forum/#!forum/netbox-discuss).
We also have a Google Groups [mailing list](https://groups.google.com/g/netbox-discuss)
for general discussion, however we're encouraging people to use GitHub
discussions where possible, as it's much easier for newcomers to review past
discussions.
### Slack
For real-time discussion, you can join the #netbox Slack channel on [NetworkToCode](https://slack.networktocode.com/).
For real-time chat, you can join the **#netbox** Slack channel on [NetworkToCode](https://slack.networktocode.com/).
Unfortunately, the Slack channel does not provide long-term retention of chat
history, so try to avoid it for any discussions would benefit from being
preserved for future reference.
## Reporting Bugs
@@ -93,6 +111,10 @@ appropriate labels will be applied for categorization.
## Submitting Pull Requests
* If you're interested in contributing to NetBox, be sure to check out our
[getting started](https://netbox.readthedocs.io/en/stable/development/getting-started/)
documentation for tips on setting up your development environment.
* Be sure to open an issue **before** starting work on a pull request, and
discuss your idea with the NetBox maintainers before beginning work. This will
help prevent wasting time on something that might we might not be able to
@@ -103,14 +125,18 @@ any work that's already in progress.
be assigned to you so that others are aware it's being worked on. A maintainer
will then mark the issue as "accepted."
* Any pull request which does _not_ relate to an accepted issue will be closed.
* Any pull request which does _not_ relate to an **accepted** issue will be closed.
* All major new functionality must include relevant tests where applicable.
* All new functionality must include relevant tests where applicable.
* When submitting a pull request, please be sure to work off of the `develop`
branch, rather than `master`. The `develop` branch is used for ongoing
development, while `master` is used for tagging stable releases.
* In most cases, it is not necessary to add a changelog entry: A maintainer will
take care of this when the PR is merged. (This helps avoid merge conflicts
resulting from multiple PRs being submitted simultaneously.)
* All code submissions should meet the following criteria (CI will enforce
these checks):
@@ -163,8 +189,8 @@ overlooked.
* Official channels for communication include:
* GitHub issues/pull requests
* The [netbox-discuss](https://groups.google.com/forum/#!forum/netbox-discuss) mailing list
* GitHub issues, pull requests, and discussions
* The [netbox-discuss](https://groups.google.com/g/netbox-discuss) mailing list
* The **#netbox** channel on [NetworkToCode Slack](https://networktocode.slack.com/)
* Maintainers with no substantial recorded activity in a 60-day period will be

View File

@@ -12,15 +12,15 @@ complete list of requirements, see `requirements.txt`. The code is available [on
The complete documentation for NetBox can be found at [Read the Docs](http://netbox.readthedocs.io/en/stable/).
Questions? Comments? Please subscribe to [the netbox-discuss mailing list](https://groups.google.com/forum/#!forum/netbox-discuss),
or join us in the #netbox Slack channel on [NetworkToCode](https://networktocode.slack.com/)!
Questions? Comments? Please start a [discussion on GitHub](https://github.com/netbox-community/netbox/discussions),
or join us in the **#netbox** Slack channel on [NetworkToCode](https://networktocode.slack.com/)!
### Build Status
| | status |
|-------------|------------|
| **master** | [![Build Status](https://travis-ci.org/netbox-community/netbox.svg?branch=master)](https://travis-ci.com/netbox-community/netbox/) |
| **develop** | [![Build Status](https://travis-ci.org/netbox-community/netbox.svg?branch=develop)](https://travis-ci.com/netbox-community/netbox/) |
| **master** | ![Build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master) |
| **develop** | ![Build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=develop) |
### Screenshots
@@ -37,18 +37,21 @@ or join us in the #netbox Slack channel on [NetworkToCode](https://networktocode
## Installation
Please see [the documentation](http://netbox.readthedocs.io/en/stable/) for
instructions on installing NetBox. To upgrade NetBox, please download the [latest release](https://github.com/netbox-community/netbox/releases)
and run `upgrade.sh`.
instructions on installing NetBox. To upgrade NetBox, please download the
[latest release](https://github.com/netbox-community/netbox/releases) and
run `upgrade.sh`.
## Providing Feedback
Feature requests and bug reports must be submitted as GiHub issues. (Please be
sure to use the [appropriate template](https://github.com/netbox-community/netbox/issues/new/choose).)
For general discussion, please consider joining our [mailing list](https://groups.google.com/forum/#!forum/netbox-discuss).
The best platform for general feedback, assistance, and other discussion is our
[GitHub discussions](https://github.com/netbox-community/netbox/discussions).
To report a bug or request a specific feature, please open a GitHub issue using
the [appropriate template](https://github.com/netbox-community/netbox/issues/new/choose).
If you are interested in contributing to the development of NetBox, please read
our [contributing guide](CONTRIBUTING.md) prior to beginning any work.
## Related projects
Please see [our wiki](https://github.com/netbox-community/netbox/wiki/Community-Contributions) for a list of relevant community projects.
Please see [our wiki](https://github.com/netbox-community/netbox/wiki/Community-Contributions)
for a list of relevant community projects.

View File

@@ -17,6 +17,18 @@ When viewing a device named Router4, this link would render as:
Custom links appear as buttons at the top right corner of the page. Numeric weighting can be used to influence the ordering of links.
## Context Data
The following context data is available within the template when rendering a custom link's text or URL.
| Variable | Description |
|----------|-------------|
| `obj` | The NetBox object being displayed |
| `debug` | A boolean indicating whether debugging is enabled |
| `request` | The current WSGI request |
| `user` | The current user (if authenticated) |
| `perms` | The [permissions](https://docs.djangoproject.com/en/stable/topics/auth/default/#permissions) assigned to the user |
## Conditional Rendering
Only links which render with non-empty text are included on the page. You can employ conditional Jinja2 logic to control the conditions under which a link gets rendered.

View File

@@ -231,6 +231,30 @@ An IPv4 or IPv6 network with a mask. Returns a `netaddr.IPNetwork` object. Two a
* `min_prefix_length` - Minimum length of the mask
* `max_prefix_length` - Maximum length of the mask
## Running Custom Scripts
!!! 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)
### Via the Web UI
Custom scripts can be run via the web UI by navigating to the script, completing any required form data, and clicking the "run script" button.
### Via the API
To run a script via the REST API, issue a POST request to the script's endpoint specifying the form data and commitment. For example, to run a script named `example.MyReport`, we would make a request such as the following:
```no-highlight
curl -X POST \
-H "Authorization: Token $TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json; indent=4" \
http://netbox/api/extras/scripts/example.MyReport/ \
--data '{"data": {"foo": "somevalue", "bar": 123}, "commit": true}'
```
## Example
Below is an example script that creates new objects for a planned site. The user is prompted for three variables:

View File

@@ -101,11 +101,14 @@ Once you have created a report, it will appear in the reports list. Initially, r
## Running Reports
!!! 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)
### Via the Web UI
Reports can be run via the web UI by navigating to the report and clicking the "run report" button at top right. Note that a user must have permission to create ReportResults in order to run reports. (Permissions can be assigned through the admin UI.)
Once a report has been run, its associated results will be included in the report view.
Reports can be run via the web UI by navigating to the report and clicking the "run report" button at top right. Once a report has been run, its associated results will be included in the report view.
### Via the API

View File

@@ -73,8 +73,9 @@ 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 by performing this command:
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
python3 manage.py invalidate all
# source /opt/netbox/venv/bin/activate
(venv) # python3 manage.py invalidate all
```

View File

@@ -277,7 +277,7 @@ The lifetime (in seconds) of the authentication cookie issued to a NetBox user u
Default: False
Setting this to True will display a "maintenance mode" banner at the top of every page.
Setting this to True will display a "maintenance mode" banner at the top of every page. Additionally, NetBox will no longer update a user's "last active" time upon login. This is to allow new logins when the database is in a read-only state. Recording of login times will resume when maintenance mode is disabled.
---
@@ -491,6 +491,14 @@ The file path to the location where custom reports will be kept. By default, thi
---
## RQ_DEFAULT_TIMEOUT
Default: `300`
The maximum execution time of a background task (such as running a custom script), in seconds.
---
## SCRIPTS_ROOT
Default: `$INSTALL_ROOT/netbox/scripts/`

View File

@@ -65,7 +65,6 @@ Redis is configured using a configuration setting similar to `DATABASE` and thes
* `PORT` - TCP port of the Redis service; leave blank for default port (6379)
* `PASSWORD` - Redis password (if set)
* `DATABASE` - Numeric database ID
* `DEFAULT_TIMEOUT` - Connection timeout in seconds
* `SSL` - Use SSL connection to Redis
An example configuration is provided below:
@@ -77,7 +76,6 @@ REDIS = {
'PORT': 1234,
'PASSWORD': 'foobar',
'DATABASE': 0,
'DEFAULT_TIMEOUT': 300,
'SSL': False,
},
'caching': {
@@ -85,7 +83,6 @@ REDIS = {
'PORT': 6379,
'PASSWORD': '',
'DATABASE': 1,
'DEFAULT_TIMEOUT': 300,
'SSL': False,
}
}
@@ -109,6 +106,7 @@ above and the addition of two new keys.
* `SENTINELS`: List of tuples or tuple of tuples with each inner tuple containing the name or IP address
of the Redis server and port for each sentinel instance to connect to
* `SENTINEL_SERVICE`: Name of the master / service to connect to
* `SENTINEL_TIMEOUT`: Connection timeout, in seconds
Example:
@@ -117,9 +115,9 @@ REDIS = {
'tasks': {
'SENTINELS': [('mysentinel.redis.example.com', 6379)],
'SENTINEL_SERVICE': 'netbox',
'SENTINEL_TIMEOUT': 10,
'PASSWORD': '',
'DATABASE': 0,
'DEFAULT_TIMEOUT': 300,
'SSL': False,
},
'caching': {
@@ -130,7 +128,6 @@ REDIS = {
'SENTINEL_SERVICE': 'netbox',
'PASSWORD': '',
'DATABASE': 1,
'DEFAULT_TIMEOUT': 300,
'SSL': False,
}
}

View File

@@ -1,8 +1,10 @@
# Application Registry
The registry is an in-memory data structure which houses various miscellaneous application-wide parameters, such as installed plugins. It is not exposed to the user and is not intended to be modified by any code outside of NetBox core.
The registry is an in-memory data structure which houses various application-wide parameters, such as the list of enabled plugins. It is not exposed to the user and is not intended to be modified by any code outside of NetBox core.
The registry behaves essentially like a Python dictionary, with the notable exception that once a store (key) has been declared, it cannot be deleted or overwritten. The value of a store can, however, me modified; e.g. by appending a value to a list. Store values generally do not change once the application has been initialized.
The registry behaves essentially like a Python dictionary, with the notable exception that once a store (key) has been declared, it cannot be deleted or overwritten. The value of a store can, however, be modified; e.g. by appending a value to a list. Store values generally do not change once the application has been initialized.
The registry can be inspected by importing `registry` from `extras.registry`.
## Stores

View File

@@ -1,8 +1,8 @@
# Extending Models
Below is a list of items to consider when adding a new field to a model:
Below is a list of tasks to consider when adding a new field to a core model.
## 1. Generate and run database migration
## 1. Generate and run database migrations
Django migrations are used to express changes to the database schema. In most cases, Django can generate these automatically, however very complex changes may require manual intervention. Always remember to specify a short but descriptive name when generating a new migration.
@@ -14,18 +14,18 @@ Django migrations are used to express changes to the database schema. In most ca
Where possible, try to merge related changes into a single migration. For example, if three new fields are being added to different models within an app, these can be expressed in the same migration. You can merge a new migration with an existing one by combining their `operations` lists.
!!! note
Migrations can only be merged within a release. Once a new release has been published, its migrations cannot be altered.
Migrations can only be merged within a release. Once a new release has been published, its migrations cannot be altered (other than for the purpose of correcting a bug).
## 2. Add validation logic to `clean()`
If the new field introduces additional validation requirements (beyond what's included with the field itself), implement them in the model's `clean()` method. Remember to call the model's original method using `super()` before or agter your custom validation as appropriate:
If the new field introduces additional validation requirements (beyond what's included with the field itself), implement them in the model's `clean()` method. Remember to call the model's original method using `super()` before or after your custom validation as appropriate:
```
class Foo(models.Model):
def clean(self):
super(DeviceCSVForm, self).clean()
super().clean()
# Custom validation goes here
if self.bar is None:
@@ -38,7 +38,7 @@ Add the name of the new field to `csv_headers` and included a CSV-friendly repre
## 4. Update relevant querysets
If you're adding a relational field (e.g. `ForeignKey`) and intend to include the data when retreiving a list of objects, be sure to include the field using `prefetch_related()` as appropriate. This will optimize the view and avoid excessive database lookups.
If you're adding a relational field (e.g. `ForeignKey`) and intend to include the data when retreiving a list of objects, be sure to include the field using `prefetch_related()` as appropriate. This will optimize the view and avoid extraneous database queries.
## 5. Update API serializer
@@ -49,7 +49,7 @@ Extend the model's API serializer in `<app>.api.serializers` to include the new
Extend any forms to include the new field as appropriate. Common forms include:
* **Credit/edit** - Manipulating a single object
* **Bulk edit** - Performing a change on mnay objects at once
* **Bulk edit** - Performing a change on many objects at once
* **CSV import** - The form used when bulk importing objects in CSV format
* **Filter** - Displays the options available for filtering a list of objects (both UI and API)
@@ -59,7 +59,7 @@ If the new field should be filterable, add it to the `FilterSet` for the model.
## 8. Add column to object table
If the new field will be included in the object list view, add a column to the model's table. For simple fields, adding the field name to `Meta.fields` will be sufficient. More complex fields may require explicitly declaring a new column.
If the new field will be included in the object list view, add a column to the model's table. For simple fields, adding the field name to `Meta.fields` will be sufficient. More complex fields may require declaring a custom column.
## 9. Update the UI templates
@@ -76,3 +76,7 @@ Create or extend the relevant test cases to verify that the new field and any ac
* View tests
Be diligent to ensure all of the relevant test suites are adapted or extended as necessary to test any new functionality.
## 11. Update the model's documentation
Each model has a dedicated page in the documentation, at `models/<app>/<model>.md`. Update this file to include any relevant information about the new field.

View File

@@ -0,0 +1,140 @@
# Getting Started
## Setting up a Development Environment
Getting started with NetBox development is pretty straightforward, and should feel very familiar to anyone with Django development experience. There are a few things you'll need:
* A Linux system or environment
* A PostgreSQL server, which can be installed locally [per the documentation](/installation/1-postgresql/)
* A Redis server, which can also be [installed locally](/installation/2-redis/)
* A supported version of Python
### Fork the Repo
Assuming you'll be working on your own fork, your first step will be to fork the [official git repository](https://github.com/netbox-community/netbox). (If you're a maintainer who's going to be working directly with the official repo, skip this step.) You can then clone your GitHub fork locally for development:
```no-highlight
$ git clone https://github.com/youruseraccount/netbox.git
Cloning into 'netbox'...
remote: Enumerating objects: 231, done.
remote: Counting objects: 100% (231/231), done.
remote: Compressing objects: 100% (147/147), done.
remote: Total 56705 (delta 134), reused 145 (delta 84), pack-reused 56474
Receiving objects: 100% (56705/56705), 27.96 MiB | 34.92 MiB/s, done.
Resolving deltas: 100% (44177/44177), done.
$ ls netbox/
base_requirements.txt contrib docs mkdocs.yml NOTICE requirements.txt upgrade.sh
CHANGELOG.md CONTRIBUTING.md LICENSE.txt netbox README.md scripts
```
The NetBox project utilizes three long-term branches:
* `master` - Serves as a snapshot of the current stable release
* `develop` - All development on the upcoming stable release occurs here
* `develop-x.y` - Tracks work on an upcoming major release
Typically, you'll base pull requests off of the `develop` branch, or off of `develop-x.y` if you're working on a new major release. **Never** base pull requests off of the master branch, which receives merged only from the `develop` branch.
### Enable Pre-Commit Hooks
NetBox ships with a [git pre-commit hook](https://githooks.com/) script that automatically checks for style compliance and missing database migrations prior to committing changes. This helps avoid erroneous commits that result in CI test failures. You are encouraged to enable it by creating a link to `scripts/git-hooks/pre-commit`:
```no-highlight
$ cd .git/hooks/
$ ln -s ../../scripts/git-hooks/pre-commit
```
### Create a Python Virtual Environment
A [virtual environment](https://docs.python.org/3/tutorial/venv.html) is like a container for a set of Python packages. They allow you to build environments suited to specific projects without interfering with system packages or other projects. When installed per the documentation, NetBox uses a virtual environment in production.
Create a virtual environment using the `venv` Python module:
```no-highlight
$ mkdir ~/.venv
$ python3 -m venv ~/.venv/netbox
```
This will create a directory named `.venv/netbox/` in your home directory, which houses a virtual copy of the Python executable and its related libraries and tooling. When running NetBox for development, it will be run using the Python binary at `~/.venv/netbox/bin/python`.
!!! info
Keeping virtual environments in `~/.venv/` is a common convention but entirely optional: Virtual environments can be created wherever you please.
Once created, activate the virtual environment:
```no-highlight
$ source ~/.venv/netbox/bin/activate
(netbox) $
```
Notice that the console prompt changes to indicate the active environment. This updates the necessary system environment variables to ensure that any Python scripts are run within the virtual environment.
### Install Dependencies
With the virtual environment activated, install the project's required Python packages using the `pip` module:
```no-highlight
(netbox) $ python -m pip install -r requirements.txt
Collecting Django==3.1 (from -r requirements.txt (line 1))
Cache entry deserialization failed, entry ignored
Using cached https://files.pythonhosted.org/packages/2b/5a/4bd5624546912082a1bd2709d0edc0685f5c7827a278d806a20cf6adea28/Django-3.1-py3-none-any.whl
...
```
### Configure NetBox
Within the `netbox/netbox/` directory, copy `configuration.example.py` to `configuration.py` and update the following parameters:
* `ALLOWED_HOSTS`: This can be set to `['*']` for development purposes
* `DATABASE`: PostgreSQL database connection parameters
* `REDIS`: Redis configuration, if different from the defaults
* `SECRET_KEY`: Set to a random string (use `generate_secret_key.py` in the parent directory to generate a suitable key)
* `DEBUG`: Set to `True`
* `DEVELOPER`: Set to `True` (this enables the creation of new database migrations)
### Start the Development Server
Django provides a lightweight, auto-updating HTTP/WSGI server for development use. NetBox extends this slightly to automatically import models and other utilities. Run the NetBox development server with the `nbshell` management command:
```no-highlight
$ python netbox/manage.py runserver
Performing system checks...
System check identified no issues (0 silenced).
November 18, 2020 - 15:52:31
Django version 3.1, using settings 'netbox.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.
```
This ensures that your development environment is now complete and operational. Any changes you make to the code base will be automatically adapted by the development server.
## Running Tests
Throughout the course of development, it's a good idea to occasionally run NetBox's test suite to catch any potential errors. Tests are run using the `test` management command:
```no-highlight
$ python netbox/manage.py test
```
In cases where you haven't made any changes to the database (which is most of the time), you can append the `--keepdb` argument to this command to reuse the test database between runs. This cuts down on the time it takes to run the test suite since the database doesn't have to be rebuilt each time. (Note that this argument will cause errors if you've modified any model fields since the previous test run.)
```no-highlight
$ python netbox/manage.py test --keepdb
```
## Submitting Pull Requests
Once you're happy with your work and have verified that all tests pass, commit your changes and push it upstream to your fork. Always provide descriptive (but not excessively verbose) commit messages. When working on a specific issue, be sure to reference it.
```no-highlight
$ git commit -m "Closes #1234: Add IPv5 support"
$ git push origin
```
Once your fork has the new commit, submit a [pull request](https://github.com/netbox-community/netbox/compare) to the NetBox repo to propose the changes. Be sure to provide a detailed accounting of the changes being made and the reasons for doing so.
Once submitted, a maintainer will review your pull request and either merge it or request changes. If changes are needed, you can make them via new commits to your fork: The pull request will update automatically.
!!! note
Remember, pull requests are entertained only for **accepted** issues. If an issue you want to work on hasn't been approved by a maintainer yet, it's best to avoid risking your time and effort on a change that might not be accepted.

View File

@@ -7,7 +7,8 @@ NetBox is maintained as a [GitHub project](https://github.com/netbox-community/n
Communication among developers should always occur via public channels:
* [GitHub issues](https://github.com/netbox-community/netbox/issues) - All feature requests, bug reports, and other substantial changes to the code base **must** be documented in an issue.
* [The mailing list](https://groups.google.com/forum/#!forum/netbox-discuss) - The preferred forum for general discussion and support issues. Ideal for shaping a feature request prior to submitting an issue.
* [GitHub discussions](https://github.com/netbox-community/netbox/discussions) - The preferred forum for general discussion and support issues. Ideal for shaping a feature request prior to submitting an issue.
* [The mailing list](https://groups.google.com/g/netbox-discuss) - An alternative forum for general discussion (GitHub is preferred).
* [#netbox on NetworkToCode](http://slack.networktocode.com/) - Good for quick chats. Avoid any discussion that might need to be referenced later on, as the chat history is not retained long.
## Governance
@@ -18,7 +19,7 @@ NetBox follows the [benevolent dictator](http://oss-watch.ac.uk/resources/benevo
All development of the current NetBox release occurs in the `develop` branch; releases are packaged from the `master` branch. The `master` branch should _always_ represent the current stable release in its entirety, such that installing NetBox by either downloading a packaged release or cloning the `master` branch provides the same code base.
NetBox components are arranged into functional subsections called _apps_ (a carryover from Django verancular). Each app holds the models, views, and templates relevant to a particular function:
NetBox components are arranged into functional subsections called _apps_ (a carryover from Django vernacular). Each app holds the models, views, and templates relevant to a particular function:
* `circuits`: Communications circuits and providers (not to be confused with power circuits)
* `dcim`: Datacenter infrastructure management (sites, racks, and devices)
@@ -26,5 +27,6 @@ NetBox components are arranged into functional subsections called _apps_ (a carr
* `ipam`: IP address management (VRFs, prefixes, IP addresses, and VLANs)
* `secrets`: Encrypted storage of sensitive data (e.g. login credentials)
* `tenancy`: Tenants (such as customers) to which NetBox objects may be assigned
* `users`: Authentication and user preferences
* `utilities`: Resources which are not user-facing (extendable classes, etc.)
* `virtualization`: Virtual machines and clusters

View File

@@ -16,28 +16,24 @@ The other file is `requirements.txt`, which lists each of the required packages
Every minor version release should refresh `requirements.txt` so that it lists the most recent stable release of each package. To do this:
1. Create a new virtual environment.
2. Install the latest version of all required packages via pip:
```
pip install -U -r base_requirements.txt
```
2. Install the latest version of all required packages `pip install -U -r base_requirements.txt`).
3. Run all tests and check that the UI and API function as expected.
4. Update the package versions in `requirements.txt` as appropriate.
4. Review each requirement's release notes for any breaking or otherwise noteworthy changes.
5. Update the package versions in `requirements.txt` as appropriate.
### Update Static Libraries
Update the following static libraries to their most recent stable release:
* Bootstrap 3
* Font Awesome 4
* Material Design Icons
* Select2
* jQuery
* jQuery UI
### Create a new Release Notes Page
### Link to the Release Notes Page
Create a file at `/docs/release-notes/X.Y.md` to establish the release notes for the new release. Add the file to the table of contents within `mkdocs.yml`, and point `index.md` to the new file.
Add the release notes (`/docs/release-notes/X.Y.md`) to the table of contents within `mkdocs.yml`, and point `index.md` to the new file.
### Manually Perform a New Install
@@ -52,7 +48,14 @@ Follow these instructions to perform a new installation of NetBox. This process
### Close the Release Milestone
Close the release milestone on GitHub. Ensure that there are no remaining open issues associated with it.
Close the release milestone on GitHub after ensuring there are no remaining open issues associated with it.
### Merge the Release Branch
Submit a pull request to merge the release branch `develop-x.y` into the `develop` branch in preparation for its releases.
!!! warning
No further releases for the current major version can be published once this pull request is merged.
---
@@ -64,11 +67,11 @@ Ensure that continuous integration testing on the `develop` branch is completing
### Update Version and Changelog
Update the `VERSION` constant in `settings.py` to the new release version and annotate the current data in the release notes for the new version.
Update the `VERSION` constant in `settings.py` to the new release version and annotate the current data in the release notes for the new version. Commit these changes to the `develop` branch.
### Submit a Pull Request
Submit a pull request title **"Release vX.Y.Z"** to merge the `develop` branch into `master`. Include a brief change log listing the features, improvements, and/or bugs addressed in the release.
Submit a pull request title **"Release vX.Y.Z"** to merge the `develop` branch into `master`. Copy the documented release notes into the pull request's body.
Once CI has completed on the PR, merge it.
@@ -76,20 +79,16 @@ Once CI has completed on the PR, merge it.
Draft a [new release](https://github.com/netbox-community/netbox/releases/new) with the following parameters.
* **Tag:** Current version (e.g. `v2.3.4`)
* **Tag:** Current version (e.g. `v2.9.9`)
* **Target:** `master`
* **Title:** Version and date (e.g. `v2.3.4 - 2018-08-02`)
* **Title:** Version and date (e.g. `v2.9.9 - 2020-11-09`)
Copy the description from the pull request into the release notes.
Copy the description from the pull request to the release.
### Update the Development Version
On the `develop` branch, update `VERSION` in `settings.py` to point to the next release. For example, if you just released v2.3.4, set:
On the `develop` branch, update `VERSION` in `settings.py` to point to the next release. For example, if you just released v2.9.9, set:
```
VERSION = 'v2.3.5-dev'
VERSION = 'v2.9.10-dev'
```
### Announce the Release
Announce the release on the [mailing list](https://groups.google.com/forum/#!forum/netbox-discuss). Include a link to the release and the (HTML-formatted) release notes.

View File

@@ -5,8 +5,8 @@ NetBox generally follows the [Django style guide](https://docs.djangoproject.com
## PEP 8 Exceptions
* Wildcard imports (for example, `from .constants import *`) are acceptable under any of the following conditions:
* The library being import contains only constant declarations (`constants.py`)
* The library being imported explicitly defines `__all__` (e.g. `<app>.api.nested_serializers`)
* The library being import contains only constant declarations (e.g. `constants.py`)
* The library being imported explicitly defines `__all__`
* Maximum line length is 120 characters (E501)
* This does not apply to HTML templates or to automatically generated code (e.g. database migrations).
@@ -45,7 +45,7 @@ When adding a new dependency, a short description of the package and the URL of
* When in doubt, remain consistent: It is better to be consistently incorrect than inconsistently correct. If you notice in the course of unrelated work a pattern that should be corrected, continue to follow the pattern for now and open a bug so that the entire code base can be evaluated at a later point.
* Prioritize readability over concision. Python is a very flexible language that typically gives us several options for expressing a given piece of logic, but some may be more friendly to the reader than others. (List comprehensions are particularly vulnerable to over-optimization.) Always remain considerate of the future reader who may need to interpret your code without the benefit of the context within which you are writing it.
* Prioritize readability over concision. Python is a very flexible language that typically offers several options for expressing a given piece of logic, but some may be more friendly to the reader than others. (List comprehensions are particularly vulnerable to over-optimization.) Always remain considerate of the future reader who may need to interpret your code without the benefit of the context within which you are writing it.
* No easter eggs. While they can be fun, NetBox must be considered as a business-critical tool. The potential, however minor, for introducing a bug caused by unnecessary logic is best avoided entirely.

View File

@@ -8,4 +8,4 @@ The `users.UserConfig` model holds individual preferences for each user in the f
| ---- | ----------- |
| extras.configcontext.format | Preferred format when rendering config context data (JSON or YAML) |
| pagination.per_page | The number of items to display per page of a paginated table |
| tables.${table_name}.columns | The ordered list of columns to display when viewing the table |
| tables.TABLE_NAME.columns | The ordered list of columns to display when viewing the table |

View File

@@ -1,57 +0,0 @@
# Utility Views
Utility views are reusable views that handle common CRUD tasks, such as listing and updating objects. Some views operate on individual objects, whereas others (referred to as "bulk" views) operate on multiple objects at once.
## Individual Views
### ObjectView
Retrieve and display a single object.
### ObjectListView
Generates a paginated table of objects from a given queryset, which may optionally be filtered.
### ObjectEditView
Updates an object identified by a primary key (PK) or slug. If no existing object is specified, a new object will be created.
### ObjectDeleteView
Deletes an object. The user is redirected to a confirmation page before the deletion is executed.
## Bulk Views
### BulkCreateView
Creates multiple objects at once based on a given pattern. Currently used only for IP addresses.
### BulkImportView
Accepts CSV-formatted data and creates a new object for each line. Creation is all-or-none.
### BulkEditView
Applies changes to multiple objects at once in a two-step operation. First, the list of PKs for selected objects is POSTed and an edit form is presented to the user. On submission of that form, the specified changes are made to all selected objects.
### BulkDeleteView
Deletes multiple objects. The user selects the objects to be deleted and confirms the deletion.
## Component Views
### ComponentCreateView
Create one or more component objects beloning to a parent object (e.g. interfaces attached to a device).
### ComponentEditView
A subclass of `ObjectEditView`: Updates an individual component object.
### ComponentDeleteView
A subclass of `ObjectDeleteView`: Deletes an individual component object.
### BulkComponentCreateView
Create a set of components objects for each of a selected set of parent objects. This view can be used e.g. to create multiple interfaces on multiple devices at once.

View File

@@ -25,7 +25,7 @@ Begin by installing all system packages required by NetBox and its dependencies.
Before continuing with either platform, update pip (Python's package management tool) to its latest release:
```no-highlight
# pip install --upgrade pip
# pip3 install --upgrade pip
```
## Download NetBox
@@ -163,7 +163,6 @@ REDIS = {
'PORT': 6379, # Redis port
'PASSWORD': '', # Redis password (optional)
'DATABASE': 0, # Database ID
'DEFAULT_TIMEOUT': 300, # Timeout (seconds)
'SSL': False, # Use SSL (optional)
},
'caching': {
@@ -171,7 +170,6 @@ REDIS = {
'PORT': 6379,
'PASSWORD': '',
'DATABASE': 1, # Unique ID for second database
'DEFAULT_TIMEOUT': 300,
'SSL': False,
}
}

View File

@@ -3,7 +3,7 @@
This documentation provides example configurations for both [nginx](https://www.nginx.com/resources/wiki/) and [Apache](http://httpd.apache.org/docs/2.4), though any HTTP server which supports WSGI should be compatible.
!!! info
For the sake of brevity, only Ubuntu 18.04 instructions are provided here, these tasks not unique to NetBox and should carry over to other distributions with mininal changes. Please consult your distribution's documentation for assistance if needed.
For the sake of brevity, only Ubuntu 18.04 instructions are provided here, these tasks are not unique to NetBox and should carry over to other distributions with minimal changes. Please consult your distribution's documentation for assistance if needed.
## Obtain an SSL Certificate

View File

@@ -143,17 +143,28 @@ AUTH_LDAP_CACHE_TIMEOUT = 3600
`systemctl restart netbox` restarts the Netbox service, and initiates any changes made to `ldap_config.py`. If there are syntax errors present, the NetBox process will not spawn an instance, and errors should be logged to `/var/log/messages`.
For troubleshooting LDAP user/group queries, add the following lines to the start of `ldap_config.py` after `import ldap`.
For troubleshooting LDAP user/group queries, add or merge the following [logging](/configuration/optional-settings.md#logging) configuration to `configuration.py`:
```python
import logging, logging.handlers
logfile = "/opt/netbox/logs/django-ldap-debug.log"
my_logger = logging.getLogger('django_auth_ldap')
my_logger.setLevel(logging.DEBUG)
handler = logging.handlers.RotatingFileHandler(
logfile, maxBytes=1024 * 500, backupCount=5
)
my_logger.addHandler(handler)
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'handlers': {
'netbox_auth_log': {
'level': 'DEBUG',
'class': 'logging.handlers.RotatingFileHandler',
'filename': '/opt/netbox/logs/django-ldap-debug.log',
'maxBytes': 1024 * 500,
'backupCount': 5,
},
},
'loggers': {
'django_auth_ldap': {
'handlers': ['netbox_auth_log'],
'level': 'DEBUG',
},
},
}
```
Ensure the file and path specified in logfile exist and are writable and executable by the application service account. Restart the netbox service and attempt to log into the site to trigger log entries to this file.

View File

@@ -38,14 +38,14 @@ You can use the command `systemctl status netbox` to verify that the WSGI servic
# systemctl status netbox.service
● netbox.service - NetBox WSGI Service
Loaded: loaded (/etc/systemd/system/netbox.service; enabled; vendor preset: enabled)
Active: active (running) since Thu 2019-12-12 19:23:40 UTC; 25s ago
Active: active (running) since Sat 2020-10-24 19:23:40 UTC; 25s ago
Docs: https://netbox.readthedocs.io/en/stable/
Main PID: 11993 (gunicorn)
Tasks: 6 (limit: 2362)
CGroup: /system.slice/netbox.service
├─11993 /usr/bin/python3 /usr/local/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/...
├─12015 /usr/bin/python3 /usr/local/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/...
├─12016 /usr/bin/python3 /usr/local/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/...
├─11993 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/...
├─12015 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/...
├─12016 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/...
...
```

View File

@@ -4,8 +4,15 @@
Prior to upgrading your NetBox instance, be sure to carefully review all [release notes](../../release-notes/) that have been published since your current version was released. Although the upgrade process typically does not involve additional work, certain releases may introduce breaking or backward-incompatible changes. These are called out in the release notes under the version in which the change went into effect.
!!! note
Beginning with version 2.8, NetBox requires Python 3.6 or later.
## Update Dependencies to Required Versions
NetBox v2.9.0 and later requires the following:
| Dependency | Minimum Version |
|------------|-----------------|
| Python | 3.6 |
| PostgreSQL | 9.6 |
| Redis | 4.0 |
## Install the Latest Code
@@ -21,44 +28,44 @@ Download and extract the latest version:
# wget https://github.com/netbox-community/netbox/archive/vX.Y.Z.tar.gz
# tar -xzf vX.Y.Z.tar.gz -C /opt
# cd /opt/
# ln -sfn netbox-X.Y.Z/ netbox
# ln -sfn /opt/netbox-X.Y.Z/ /opt/netbox
```
Copy the 'configuration.py' you created when first installing to the new version:
```no-highlight
# cp netbox-X.Y.Z/netbox/netbox/configuration.py netbox/netbox/netbox/configuration.py
# cp /opt/netbox-X.Y.Z/netbox/netbox/configuration.py /opt/netbox/netbox/netbox/configuration.py
```
Copy your local requirements file if used:
```no-highlight
# cp netbox-X.Y.Z/local_requirements.txt netbox/local_requirements.txt
# cp /opt/netbox-X.Y.Z/local_requirements.txt /opt/netbox/local_requirements.txt
```
Also copy the LDAP configuration if using LDAP:
```no-highlight
# cp netbox-X.Y.Z/netbox/netbox/ldap_config.py netbox/netbox/netbox/ldap_config.py
# cp /opt/netbox-X.Y.Z/netbox/netbox/ldap_config.py /opt/netbox/netbox/netbox/ldap_config.py
```
Be sure to replicate your uploaded media as well. (The exact action necessary will depend on where you choose to store your media, but in general moving or copying the media directory will suffice.)
```no-highlight
# cp -pr netbox-X.Y.Z/netbox/media/ netbox/netbox/
# cp -pr /opt/netbox-X.Y.Z/netbox/media/ /opt/netbox/netbox/
```
Also make sure to copy over any custom scripts and reports that you've made. Note that if these are stored outside the project root, you will not need to copy them. (Check the `SCRIPTS_ROOT` and `REPORTS_ROOT` parameters in the configuration file above if you're unsure.)
```no-highlight
# cp -r /opt/netbox-X.Y.Z/netbox/scripts /opt/netbox/netbox/scripts/
# cp -r /opt/netbox-X.Y.Z/netbox/reports /opt/netbox/netbox/reports/
# cp -r /opt/netbox-X.Y.Z/netbox/scripts /opt/netbox/netbox/
# cp -r /opt/netbox-X.Y.Z/netbox/reports /opt/netbox/netbox/
```
If you followed the original installation guide to set up gunicorn, be sure to copy its configuration as well:
```no-highlight
# cp netbox-X.Y.Z/gunicorn.py netbox/gunicorn.py
# cp /opt/netbox-X.Y.Z/gunicorn.py /opt/netbox/gunicorn.py
```
### Option B: Clone the Git Repository

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

View File

@@ -4,7 +4,7 @@ Interfaces in NetBox represent network interfaces used to exchange data with con
Interfaces may be physical or virtual in nature, but only physical interfaces may be connected via cables. Cables can connect interfaces to pass-through ports, circuit terminations, or other interfaces.
Physical interfaces may be arranged into a link aggregation group (LAG) and associated with a parent LAG (virtual) interface. Like all virtual interfaces, LAG interfaces cannot be connected physically.
Physical interfaces may be arranged into a link aggregation group (LAG) and associated with a parent LAG (virtual) interface. LAG interfaces can be recursively nested to model bonding of trunk groups. Like all virtual interfaces, LAG interfaces cannot be connected physically.
IP addresses can be assigned to interfaces. VLANs can also be assigned to each interface as either tagged or untagged. (An interface may have only one untagged VLAN.)

View File

@@ -1,3 +1,3 @@
## Rear Port Templates
A template for a rear-facing pass-through port that will be created on all instantiations of the parent device type. Each rear port may have a physical type and one or more front port templates assigned to it. The number of positions associated with a rear port determines how many front ports can be assigned to it (the maximum is 64).
A template for a rear-facing pass-through port that will be created on all instantiations of the parent device type. Each rear port may have a physical type and one or more front port templates assigned to it. The number of positions associated with a rear port determines how many front ports can be assigned to it (the maximum is 1024).

View File

@@ -10,6 +10,7 @@ Each IP address can also be assigned an operational status and a functional role
* Reserved
* Deprecated
* DHCP
* SLAAC (IPv6 Stateless Address Autoconfiguration)
Roles are used to indicate some special attribute of an IP address; for example, use as a loopback or as the the virtual IP for a VRRP group. (Note that functional roles are conceptual in nature, and thus cannot be customized by the user.) Available roles include:

View File

@@ -12,6 +12,9 @@ Plugins can do a lot, including:
However, keep in mind that each piece of functionality is entirely optional. For example, if your plugin merely adds a piece of middleware or an API endpoint for existing data, there's no need to define any new models.
!!! warning
While very powerful, the NetBox plugins API is necessarily limited in its scope. The plugins API is discussed here in its entirety: Any part of the NetBox code base not documented here is _not_ part of the supported plugins API, and should not be employed by a plugin. Internal elements of NetBox are subject to change at any time and without warning. Plugin authors are **strongly** encouraged to develop plugins using only the officially supported components discussed here and those provided by the underlying Django framework so as to avoid breaking changes in future releases.
## Initial Setup
## Plugin Structure
@@ -198,26 +201,37 @@ class RandomAnimalView(View):
})
```
This view retrieves a random animal from the database and and passes it as a context variable when rendering a template named `animal.html`, which doesn't exist yet. To create this template, first create a directory named `templates/netbox_animal_sounds/` within the plugin source directory. (We use the plugin's name as a subdirectory to guard against naming collisions with other plugins.) Then, create `animal.html`:
This view retrieves a random animal from the database and and passes it as a context variable when rendering a template named `animal.html`, which doesn't exist yet. To create this template, first create a directory named `templates/netbox_animal_sounds/` within the plugin source directory. (We use the plugin's name as a subdirectory to guard against naming collisions with other plugins.) Then, create a template named `animal.html` as described below.
### Extending the Base Template
NetBox provides a base template to ensure a consistent user experience, which plugins can extend with their own content. This template includes four content blocks:
* `title` - The page title
* `header` - The upper portion of the page
* `content` - The main page body
* `javascript` - A section at the end of the page for including Javascript code
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' %}
{% block content %}
{% with config=settings.PLUGINS_CONFIG.netbox_animal_sounds %}
<h2 class="text-center" style="margin-top: 200px">
{% if animal %}
The {{ animal.name|lower }} says
{% if config.loud %}
{{ animal.sound|upper }}!
{% else %}
{{ animal.sound }}
{% endif %}
{% else %}
No animals have been created yet!
{% endif %}
</h2>
{% endwith %}
{% with config=settings.PLUGINS_CONFIG.netbox_animal_sounds %}
<h2 class="text-center" style="margin-top: 200px">
{% if animal %}
The {{ animal.name|lower }} says
{% if config.loud %}
{{ animal.sound|upper }}!
{% else %}
{{ animal.sound }}
{% endif %}
{% else %}
No animals have been created yet!
{% endif %}
</h2>
{% endwith %}
{% endblock %}
```
@@ -328,6 +342,9 @@ A `PluginMenuButton` has the following attributes:
* `color` - One of the choices provided by `ButtonColorChoices` (optional)
* `permissions` - A list of permissions required to display this button (optional)
!!! note
Any buttons associated within a menu item will be shown only if the user has permission to view the link, regardless of what permissions are set on the buttons.
## Extending Core Templates
Plugins can inject custom content into certain areas of the detail views of applicable models. This is accomplished by subclassing `PluginTemplateExtension`, designating a particular NetBox model, and defining the desired methods to render custom content. Four methods are available:

View File

@@ -64,6 +64,15 @@ PLUGINS_CONFIG = {
}
```
### Run Database Migrations
If the plugin introduces new database models, run the provided schema migrations:
```no-highlight
(venv) $ cd /opt/netbox/netbox/
(venv) $ python3 manage.py migrate
```
### Collect Static Files
Plugins may package static files to be served directly by the HTTP front end. Ensure that these are copied to the static root directory with the `collectstatic` management command:

View File

@@ -1,18 +1,5 @@
# NetBox v2.8
## v2.8.10 (FUTURE)
### Enhancements
* [#4885](https://github.com/netbox-community/netbox/issues/4885) - Add MultiChoiceVar for custom scripts
### Bug Fixes
* [#4992](https://github.com/netbox-community/netbox/issues/4992) - Add `display_name` to nested VRF serializer
* [#4993](https://github.com/netbox-community/netbox/issues/4993) - Add `cable` to nested CircuitTermination serializer
---
## v2.8.9 (2020-08-04)
### Enhancements

View File

@@ -1,51 +1,218 @@
# NetBox v2.9
## v2.9-beta2 (2020-08-13)
**WARNING:** This is a beta release and is not suitable for production use. It is intended for development and evaluation purposes only. No upgrade path to the final v2.9 release will be provided from this beta, and users should assume that all data entered into the application will be lost. Please reference [the v2.9 beta documentation](https://netbox.readthedocs.io/en/develop-2.9/) for further information regarding this release.
## v2.9.11 (2020-12-11)
### Enhancements
* [#4639](https://github.com/netbox-community/netbox/issues/4639) - Improve performance of web UI prefixes list
* [#4919](https://github.com/netbox-community/netbox/issues/4919) - Allow adding/changing assigned permissions within group and user admin views
* [#4922](https://github.com/netbox-community/netbox/issues/4922) - Optimize schema migration for replicating VM interfaces
* [#4940](https://github.com/netbox-community/netbox/issues/4940) - Add an `occupied` field to rack unit representations for rack elevation views
* [#4945](https://github.com/netbox-community/netbox/issues/4945) - Add a user-friendly 403 error page
* [#4946](https://github.com/netbox-community/netbox/issues/4946) - Extend ObjectPermission to OR multiple constraints
* [#4969](https://github.com/netbox-community/netbox/issues/4969) - Replace secret role user/group assignment with object permissions
* [#4982](https://github.com/netbox-community/netbox/issues/4982) - Extended ObjectVar to allow filtering API query
* [#4994](https://github.com/netbox-community/netbox/issues/4994) - Add `cable` attribute to PowerFeed API serializer
* [#4996](https://github.com/netbox-community/netbox/issues/4996) - Add "connect" buttons to individual device component views
* [#4997](https://github.com/netbox-community/netbox/issues/4997) - The browsable API now lists available endpoints alphabetically
* [#5424](https://github.com/netbox-community/netbox/issues/5424) - Allow passing Python code to `nbshell` using `--command`
* [#5439](https://github.com/netbox-community/netbox/issues/5439) - Add CS and SN fiber port types
### Bug Fixes
* [#4903](https://github.com/netbox-community/netbox/issues/4903) - Fix member count when searching for virtual chassis
* [#4905](https://github.com/netbox-community/netbox/issues/4905) - Fix front port count on device type view
* [#4912](https://github.com/netbox-community/netbox/issues/4912) - Fix image attachment API endpoint
* [#4914](https://github.com/netbox-community/netbox/issues/4914) - Fix toggling cable status under device view
* [#4921](https://github.com/netbox-community/netbox/issues/4921) - Render non-viewable devices as unavailable space in rack elevations
* [#4930](https://github.com/netbox-community/netbox/issues/4930) - Replicate label values when instantiating device type components
* [#4931](https://github.com/netbox-community/netbox/issues/4931) - Fix DoesNotExist exception when deleting devices
* [#4938](https://github.com/netbox-community/netbox/issues/4938) - Show add, import buttons on virtual chassis list view
* [#4939](https://github.com/netbox-community/netbox/issues/4939) - Fix linking to LAG interfaces on other VC members
* [#4950](https://github.com/netbox-community/netbox/issues/4950) - Include inventory item label in API serializer, UI view
* [#4951](https://github.com/netbox-community/netbox/issues/4951) - Redirect to device inventory view after creating a new inventory item
* [#4952](https://github.com/netbox-community/netbox/issues/4952) - Default to VM tab when creating/editing an IP address for a VM
* [#4968](https://github.com/netbox-community/netbox/issues/4968) - Fix exception when activating user keys in admin UI
* [#4995](https://github.com/netbox-community/netbox/issues/4995) - Fix missing buttons to add console/power ports under device view
### Other Changes
* [#4940](https://github.com/netbox-community/netbox/issues/4940) - Add an `occupied` field to rack unit representations for rack elevation views
* [#4942](https://github.com/netbox-community/netbox/issues/4942) - Make ObjectPermission's `name` field required
* [#4943](https://github.com/netbox-community/netbox/issues/4943) - Add a `description` field to ObjectPermission
* [#5383](https://github.com/netbox-community/netbox/issues/5383) - Fix setting user password via REST API
* [#5396](https://github.com/netbox-community/netbox/issues/5396) - Fix uniqueness constraint for virtual machine names
* [#5387](https://github.com/netbox-community/netbox/issues/5387) - Fix error when rendering config contexts when objects have multiple tags assigned
* [#5407](https://github.com/netbox-community/netbox/issues/5407) - Add direct link to secret on secrets list
* [#5408](https://github.com/netbox-community/netbox/issues/5408) - Fix updating secrets without setting new plaintext
* [#5410](https://github.com/netbox-community/netbox/issues/5410) - Restore tags field on cable connection forms
* [#5433](https://github.com/netbox-community/netbox/issues/5433) - Exclude SVG files from front/rear image upload for device types (currently unsupported)
* [#5436](https://github.com/netbox-community/netbox/issues/5436) - Show assigned IP addresses in interfaces list
* [#5446](https://github.com/netbox-community/netbox/issues/5446) - Fix validation for plugin version and required settings
---
## v2.9-beta1 (2020-07-23)
## v2.9.10 (2020-11-24)
**WARNING:** This is a beta release and is not suitable for production use. It is intended for development and evaluation purposes only. No upgrade path to the final v2.9 release will be provided from this beta, and users should assume that all data entered into the application will be lost. Please reference [the v2.9 beta documentation](https://netbox.readthedocs.io/en/develop-2.9/) for further information regarding this release.
### Enhancements
* [#5319](https://github.com/netbox-community/netbox/issues/5319) - Add USB types for power ports and outlets
* [#5337](https://github.com/netbox-community/netbox/issues/5337) - Add "splice" type for pass-through ports
### Bug Fixes
* [#5235](https://github.com/netbox-community/netbox/issues/5235) - Fix exception when editing IP address with a NAT IP assigned to a non-racked device
* [#5309](https://github.com/netbox-community/netbox/issues/5309) - Avoid extraneous database queries when manipulating objects
* [#5345](https://github.com/netbox-community/netbox/issues/5345) - Fix non-deterministic ordering of prefixes and IP addresses
* [#5350](https://github.com/netbox-community/netbox/issues/5350) - Filter available racks by selected group when creating a rack reservation
* [#5355](https://github.com/netbox-community/netbox/issues/5355) - Limit rack groups by selected site when editing a rack
* [#5356](https://github.com/netbox-community/netbox/issues/5356) - Populate manufacturer field when adding a device component template
* [#5360](https://github.com/netbox-community/netbox/issues/5360) - Clear VLAN assignments when setting interface mode to none
---
## v2.9.9 (2020-11-09)
### Enhancements
* [#5304](https://github.com/netbox-community/netbox/issues/5304) - Return server error messages as JSON when handling REST API requests
* [#5310](https://github.com/netbox-community/netbox/issues/5310) - Link to rack groups within rack list table
* [#5327](https://github.com/netbox-community/netbox/issues/5327) - Be more strict when capturing anticipated ImportError exceptions
### Bug Fixes
* [#5271](https://github.com/netbox-community/netbox/issues/5271) - Fix auto-population of region field when editing a device
* [#5314](https://github.com/netbox-community/netbox/issues/5314) - Fix config context rendering when multiple tags are assigned to an object
* [#5316](https://github.com/netbox-community/netbox/issues/5316) - Dry running scripts should not trigger webhooks
* [#5324](https://github.com/netbox-community/netbox/issues/5324) - Add missing template extension tags for plugins for VM interface view
* [#5328](https://github.com/netbox-community/netbox/issues/5328) - Fix CreatedUpdatedFilterTest when running in non-UTC timezone
* [#5331](https://github.com/netbox-community/netbox/issues/5331) - Fix filtering of sites by null region
---
## v2.9.8 (2020-10-30)
### Enhancements
* [#4559](https://github.com/netbox-community/netbox/issues/4559) - Improve device/VM context data rendering performance
### Bug Fixes
* [#3672](https://github.com/netbox-community/netbox/issues/3672) - Fix a caching issue causing incorrect related object counts in API responses
* [#5113](https://github.com/netbox-community/netbox/issues/5113) - Fix incorrect caching of permission object assignments to user groups in the admin panel
* [#5243](https://github.com/netbox-community/netbox/issues/5243) - Redirect user to appropriate tab after modifying device components
* [#5273](https://github.com/netbox-community/netbox/issues/5273) - Fix exception when validating a new permission with no models selected
* [#5282](https://github.com/netbox-community/netbox/issues/5282) - Fix high CPU load when LDAP authentication is enabled
* [#5285](https://github.com/netbox-community/netbox/issues/5285) - Plugins no longer need to define `app_name` for API URLs to be included in the root view
---
## v2.9.7 (2020-10-12)
### Bug Fixes
* [#5231](https://github.com/netbox-community/netbox/issues/5231) - Fix KeyError exception when viewing object with custom link and debugging is disabled
---
## v2.9.6 (2020-10-09)
### Bug Fixes
* [#5229](https://github.com/netbox-community/netbox/issues/5229) - Fix AttributeError exception when LDAP authentication is enabled
---
## v2.9.5 (2020-10-09)
### Enhancements
* [#5202](https://github.com/netbox-community/netbox/issues/5202) - Extend the available context data when rendering custom links
### Bug Fixes
* [#4523](https://github.com/netbox-community/netbox/issues/4523) - Populate site vlan list when bulk editing interfaces under certain circumstances
* [#5174](https://github.com/netbox-community/netbox/issues/5174) - Ensure consistent alignment of rack elevations
* [#5175](https://github.com/netbox-community/netbox/issues/5175) - Fix toggling of rack elevation order
* [#5184](https://github.com/netbox-community/netbox/issues/5184) - Fix missing Power Utilization
* [#5197](https://github.com/netbox-community/netbox/issues/5197) - Limit duplicate IPs shown on IP address view
* [#5199](https://github.com/netbox-community/netbox/issues/5199) - Change default LDAP logging to INFO
* [#5201](https://github.com/netbox-community/netbox/issues/5201) - Fix missing querystring when bulk editing/deleting VLAN Group VLANs when selecting "select all x items matching query"
* [#5206](https://github.com/netbox-community/netbox/issues/5206) - Apply user pagination preferences to all paginated object lists
* [#5211](https://github.com/netbox-community/netbox/issues/5211) - Add missing `has_primary_ip` filter for virtual machines
* [#5217](https://github.com/netbox-community/netbox/issues/5217) - Prevent erroneous removal of prefetched GenericForeignKey data from tables
* [#5218](https://github.com/netbox-community/netbox/issues/5218) - Raise validation error if a power port's `allocated_draw` exceeds its `maximum_draw`
* [#5220](https://github.com/netbox-community/netbox/issues/5220) - Fix API patch request against IP Address endpoint with null assigned_object_type
* [#5221](https://github.com/netbox-community/netbox/issues/5221) - Fix bulk component creation for virtual machines
* [#5224](https://github.com/netbox-community/netbox/issues/5224) - Don't allow a rear port to have fewer positions than the number of mapped front ports
* [#5226](https://github.com/netbox-community/netbox/issues/5226) - Custom choice fields should be blank initially if no default choice has been designated
---
## v2.9.4 (2020-09-23)
**NOTE:** This release removes support for the `DEFAULT_TIMEOUT` parameter under `REDIS` database configuration. Set `RQ_DEFAULT_TIMEOUT` as a global configuration parameter instead.
**NOTE:** Any permissions referencing the legacy ReportResult model (e.g. `extras.view_reportresult`) should be updated to reference the Report model.
### Enhancements
* [#1755](https://github.com/netbox-community/netbox/issues/1755) - Toggle order in which rack elevations are displayed
* [#5128](https://github.com/netbox-community/netbox/issues/5128) - Increase maximum rear port positions from 64 to 1024
* [#5134](https://github.com/netbox-community/netbox/issues/5134) - Display full hierarchy in breadcrumbs for sites/racks
* [#5149](https://github.com/netbox-community/netbox/issues/5149) - Add rack group field to device edit form
* [#5164](https://github.com/netbox-community/netbox/issues/5164) - Show total rack count per rack group under site view
* [#5171](https://github.com/netbox-community/netbox/issues/5171) - Introduce the `RQ_DEFAULT_TIMEOUT` configuration parameter
### Bug Fixes
* [#5050](https://github.com/netbox-community/netbox/issues/5050) - Fix potential failure on `0016_replicate_interfaces` schema migration from old release
* [#5066](https://github.com/netbox-community/netbox/issues/5066) - Update `view_reportresult` to `view_report` permission
* [#5075](https://github.com/netbox-community/netbox/issues/5075) - Include a VLAN membership view for VM interfaces
* [#5105](https://github.com/netbox-community/netbox/issues/5105) - Validation should fail when reassigning a primary IP from device to VM
* [#5109](https://github.com/netbox-community/netbox/issues/5109) - Fix representation of custom choice field values for webhook data
* [#5108](https://github.com/netbox-community/netbox/issues/5108) - Fix execution of reports via CLI
* [#5111](https://github.com/netbox-community/netbox/issues/5111) - Allow use of tuples when specifying ObjectVar `query_params`
* [#5118](https://github.com/netbox-community/netbox/issues/5118) - Specifying an empty list of tags should clear assigned tags (REST API)
* [#5133](https://github.com/netbox-community/netbox/issues/5133) - Fix disassociation of an IP address from a VM interface
* [#5136](https://github.com/netbox-community/netbox/issues/5136) - Fix exception when bulk editing interface 802.1Q mode
* [#5156](https://github.com/netbox-community/netbox/issues/5156) - Add missing "add" button to rack reservations list
* [#5167](https://github.com/netbox-community/netbox/issues/5167) - Support filtering ObjectChanges by multiple users
---
## v2.9.3 (2020-09-04)
### Enhancements
* [#4977](https://github.com/netbox-community/netbox/issues/4977) - Redirect authenticated users from login view
* [#5048](https://github.com/netbox-community/netbox/issues/5048) - Show the device/VM name when editing a component
* [#5072](https://github.com/netbox-community/netbox/issues/5072) - Add REST API filters for image attachments
* [#5080](https://github.com/netbox-community/netbox/issues/5080) - Add 8P6C, 8P4C, 8P2C port types
### Bug Fixes
* [#5046](https://github.com/netbox-community/netbox/issues/5046) - Disabled plugin menu items are no longer clickable
* [#5063](https://github.com/netbox-community/netbox/issues/5063) - Fix "add device" link in rack elevations for opposite side of half-depth devices
* [#5074](https://github.com/netbox-community/netbox/issues/5074) - Fix inclusion of VC member interfaces when viewing VC master
* [#5078](https://github.com/netbox-community/netbox/issues/5078) - Fix assignment of existing IP addresses to interfaces via web UI
* [#5081](https://github.com/netbox-community/netbox/issues/5081) - Fix exception during webhook processing with custom select field
* [#5085](https://github.com/netbox-community/netbox/issues/5085) - Fix ordering by assignment in IP addresses table
* [#5087](https://github.com/netbox-community/netbox/issues/5087) - Restore label field when editing console server ports, power ports, and power outlets
* [#5089](https://github.com/netbox-community/netbox/issues/5089) - Redirect to device view after editing component
* [#5090](https://github.com/netbox-community/netbox/issues/5090) - Fix status display for console/power/interface connections
* [#5091](https://github.com/netbox-community/netbox/issues/5091) - Avoid KeyError when handling invalid table preferences
* [#5095](https://github.com/netbox-community/netbox/issues/5095) - Show assigned prefixes in VLANs list
---
## v2.9.2 (2020-08-27)
### Enhancements
* [#5055](https://github.com/netbox-community/netbox/issues/5055) - Add tags column to device/VM component list tables
* [#5056](https://github.com/netbox-community/netbox/issues/5056) - Add interface and parent columns to IP address list
### Bug Fixes
* [#4988](https://github.com/netbox-community/netbox/issues/4988) - Fix ordering of rack reservations with identical creation times
* [#5002](https://github.com/netbox-community/netbox/issues/5002) - Correct OpenAPI definition for `available-prefixes` endpoint
* [#5035](https://github.com/netbox-community/netbox/issues/5035) - Fix exception when modifying an IP address assigned to a VM
* [#5038](https://github.com/netbox-community/netbox/issues/5038) - Fix validation of primary IPs assigned to virtual machines
* [#5040](https://github.com/netbox-community/netbox/issues/5040) - Limit SLAAC status to IPv6 addresses
* [#5041](https://github.com/netbox-community/netbox/issues/5041) - Fix form tabs when assigning an IP to a VM interface
* [#5042](https://github.com/netbox-community/netbox/issues/5042) - Fix display of SLAAC label for IP addresses status
* [#5045](https://github.com/netbox-community/netbox/issues/5045) - Allow assignment of interfaces to non-master VC peer LAG during import
* [#5058](https://github.com/netbox-community/netbox/issues/5058) - Correct URL for front rack elevation images when using external storage
* [#5059](https://github.com/netbox-community/netbox/issues/5059) - Fix inclusion of checkboxes for interfaces in virtual machine view
* [#5060](https://github.com/netbox-community/netbox/issues/5060) - Fix validation when bulk-importing child devices
* [#5061](https://github.com/netbox-community/netbox/issues/5061) - Allow adding/removing tags when bulk editing virtual machine interfaces
---
## v2.9.1 (2020-08-22)
### Enhancements
* [#4540](https://github.com/netbox-community/netbox/issues/4540) - Add IP address status type for SLAAC
* [#4814](https://github.com/netbox-community/netbox/issues/4814) - Allow nested LAG interfaces
* [#4991](https://github.com/netbox-community/netbox/issues/4991) - Add Python and NetBox versions to error page
* [#5033](https://github.com/netbox-community/netbox/issues/5033) - Support backward compatibility for `REMOTE_AUTH_BACKEND` configuration parameter
---
## v2.9.0 (2020-08-21)
**Note:** Redis 4.0 or later is required for this release.
### New Features
@@ -57,11 +224,29 @@ NetBox v2.9 replaces Django's built-in permissions framework with one that suppo
When running a report or custom script, its execution is now queued for background processing and the user receives an immediate response indicating its status. This prevents long-running scripts from resulting in a timeout error. Once the execution has completed, the page will automatically refresh to display its results. Both scripts and reports now store their output in the new JobResult model. (The ReportResult model has been removed.)
#### Named Virtual Chassis ([#2018](https://github.com/netbox-community/netbox/issues/2018))
The VirtualChassis model now has a mandatory `name` field. Names are assigned to the virtual chassis itself rather than referencing the master VC member. Additionally, the designation of a master is now optional: a virtual chassis may have only non-master members.
#### Changes to Tag Creation ([#3703](https://github.com/netbox-community/netbox/issues/3703))
Tags are no longer created automatically: A tag must be created by a user before it can be applied to any object. Additionally, the REST API representation of assigned tags has been expanded to be consistent with other objects.
#### Dedicated Model for VM Interfaces ([#4721](https://github.com/netbox-community/netbox/issues/4721))
A new model has been introduced to represent virtual machine interfaces. Although this change is largely transparent to the end user, note that the IP address model no longer has a foreign key to the Interface model under the DCIM app. This has been replaced with a generic foreign key named `assigned_object`.
#### REST API Endpoints for Users and Groups ([#4877](https://github.com/netbox-community/netbox/issues/4877))
Two new REST API endpoints have been added to facilitate the retrieval and manipulation of users and groups:
* `/api/users/groups/`
* `/api/users/users/`
### Enhancements
* [#2018](https://github.com/netbox-community/netbox/issues/2018) - Add `name` field to virtual chassis model
* [#3703](https://github.com/netbox-community/netbox/issues/3703) - Tags must be created administratively before being assigned to an object
* [#4615](https://github.com/netbox-community/netbox/issues/4615) - Add `label` field for all device components and component templates
* [#4639](https://github.com/netbox-community/netbox/issues/4639) - Improve performance of web UI prefixes list
* [#4742](https://github.com/netbox-community/netbox/issues/4742) - Add tagging for cables, power panels, and rack reservations
* [#4788](https://github.com/netbox-community/netbox/issues/4788) - Add dedicated views for all device components
* [#4792](https://github.com/netbox-community/netbox/issues/4792) - Add bulk rename capability for console and power ports
@@ -72,12 +257,21 @@ When running a report or custom script, its execution is now queued for backgrou
* [#4817](https://github.com/netbox-community/netbox/issues/4817) - Standardize device/VM component `name` field to 64 characters
* [#4837](https://github.com/netbox-community/netbox/issues/4837) - Use dynamic form widget for relationships to MPTT objects (e.g. regions)
* [#4840](https://github.com/netbox-community/netbox/issues/4840) - Enable change logging for config contexts
* [#4877](https://github.com/netbox-community/netbox/issues/4877) - Add REST API endpoints for users and groups
* [#4885](https://github.com/netbox-community/netbox/issues/4885) - Add MultiChoiceVar for custom scripts
* [#4940](https://github.com/netbox-community/netbox/issues/4940) - Add an `occupied` field to rack unit representations for rack elevation views
* [#4945](https://github.com/netbox-community/netbox/issues/4945) - Add a user-friendly 403 error page
* [#4969](https://github.com/netbox-community/netbox/issues/4969) - Replace secret role user/group assignment with object permissions
* [#4982](https://github.com/netbox-community/netbox/issues/4982) - Extended ObjectVar to allow filtering API query
* [#4994](https://github.com/netbox-community/netbox/issues/4994) - Add `cable` attribute to PowerFeed API serializer
* [#4997](https://github.com/netbox-community/netbox/issues/4997) - The browsable API now lists available endpoints alphabetically
* [#5024](https://github.com/netbox-community/netbox/issues/5024) - List available options for choice fields within CSV import forms
### Configuration Changes
* If in use, LDAP authentication must be enabled by setting `REMOTE_AUTH_BACKEND` to `'netbox.authentication.LDAPBackend'`. (LDAP configuration parameters in `ldap_config.py` remain unchanged.)
* If using NetBox's built-in remote authentication backend, update `REMOTE_AUTH_BACKEND` to `'netbox.authentication.RemoteUserBackend'`, as the authentication class has moved.
* If using LDAP authentication, set `REMOTE_AUTH_BACKEND` to `'netbox.authentication.LDAPBackend'`. (LDAP configuration parameters in `ldap_config.py` remain unchanged.)
* `REMOTE_AUTH_DEFAULT_PERMISSIONS` now takes a dictionary rather than a list. This is a mapping of permission names to a dictionary of constraining attributes, or `None`. For example, `['dcim.add_site', 'dcim.change_site']` would become `{'dcim.add_site': None, 'dcim.change_site': None}`.
* Backward compatibility for the old `webhooks` Redis queue name has been dropped. Ensure that your `REDIS` configuration parameter specifies both the `tasks` and `caching` databases.
### REST API Changes
@@ -94,6 +288,7 @@ When running a report or custom script, its execution is now queued for backgrou
```
* Legacy numeric values for choice fields are no longer conveyed or accepted.
* circuits.CircuitTermination: Added `cable` field
* dcim.Cable: Added `tags` field
* dcim.ConsolePort: Added `label` field
* dcim.ConsolePortTemplate: Added `description` and `label` fields
@@ -124,6 +319,7 @@ When running a report or custom script, its execution is now queued for backgrou
* extras.Script: Added `module` and `result` fields. The `result` field now conveys the nested representation of a JobResult.
* extras.Tag: The count of `tagged_items` is no longer included when viewing the tags list when `brief` is passed.
* ipam.IPAddress: Removed `interface` field; replaced with `assigned_object` generic foreign key. This may represent either a device interface or a virtual machine interface. Assign an object by setting `assigned_object_type` and `assigned_object_id`.
* ipam.VRF: Added `display_name`
* tenancy.TenantGroup: Added a `_depth` attribute indicating an object's position in the tree.
* users.ObjectPermissions: Added the `/api/users/permissions/` endpoint
* virtualization.VMInterface: Removed `type` field (VM interfaces have no type)

View File

@@ -69,13 +69,14 @@ nav:
- Working with Secrets: 'rest-api/working-with-secrets.md'
- Development:
- Introduction: 'development/index.md'
- Getting Started: 'development/getting-started.md'
- Style Guide: 'development/style-guide.md'
- Utility Views: 'development/utility-views.md'
- Extending Models: 'development/extending-models.md'
- Application Registry: 'development/application-registry.md'
- User Preferences: 'development/user-preferences.md'
- Release Checklist: 'development/release-checklist.md'
- Release Notes:
- Version 2.9: 'release-notes/version-2.9.md'
- Version 2.8: 'release-notes/version-2.8.md'
- Version 2.7: 'release-notes/version-2.7.md'
- Version 2.6: 'release-notes/version-2.6.md'

View File

@@ -1,4 +1,4 @@
from django.db.models import Count, Prefetch
from django.db.models import Prefetch
from django.shortcuts import get_object_or_404
from rest_framework.decorators import action
from rest_framework.response import Response
@@ -10,6 +10,7 @@ from extras.api.serializers import RenderedGraphSerializer
from extras.api.views import CustomFieldModelViewSet
from extras.models import Graph
from utilities.api import ModelViewSet
from utilities.utils import get_subquery
from . import serializers
@@ -27,8 +28,8 @@ class CircuitsRootView(APIRootView):
class ProviderViewSet(CustomFieldModelViewSet):
queryset = Provider.objects.prefetch_related('tags').annotate(
circuit_count=Count('circuits')
).order_by(*Provider._meta.ordering)
circuit_count=get_subquery(Circuit, 'provider')
)
serializer_class = serializers.ProviderSerializer
filterset_class = filters.ProviderFilterSet
@@ -49,8 +50,8 @@ class ProviderViewSet(CustomFieldModelViewSet):
class CircuitTypeViewSet(ModelViewSet):
queryset = CircuitType.objects.annotate(
circuit_count=Count('circuits')
).order_by(*CircuitType._meta.ordering)
circuit_count=get_subquery(Circuit, 'type')
)
serializer_class = serializers.CircuitTypeSerializer
filterset_class = filters.CircuitTypeFilterSet

View File

@@ -303,14 +303,24 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
#
class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
region = DynamicModelChoiceField(
queryset=Region.objects.all(),
required=False,
initial_params={
'sites': '$site'
}
)
site = DynamicModelChoiceField(
queryset=Site.objects.all()
queryset=Site.objects.all(),
query_params={
'region_id': '$region'
}
)
class Meta:
model = CircuitTermination
fields = [
'term_side', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description',
'term_side', 'region', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description',
]
help_texts = {
'port_speed': "Physical circuit speed",

View File

@@ -1,13 +1,12 @@
from django.conf import settings
from django.contrib import messages
from django.db import transaction
from django.db.models import Count, Prefetch
from django.shortcuts import get_object_or_404, redirect, render
from django_tables2 import RequestConfig
from extras.models import Graph
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator
from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.utils import get_subquery
from utilities.views import (
BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
)
@@ -21,7 +20,9 @@ from .models import Circuit, CircuitTermination, CircuitType, Provider
#
class ProviderListView(ObjectListView):
queryset = Provider.objects.annotate(count_circuits=Count('circuits')).order_by(*Provider._meta.ordering)
queryset = Provider.objects.annotate(
count_circuits=get_subquery(Circuit, 'provider')
)
filterset = filters.ProviderFilterSet
filterset_form = forms.ProviderFilterForm
table = tables.ProviderTable
@@ -45,7 +46,7 @@ class ProviderView(ObjectView):
paginate = {
'paginator_class': EnhancedPaginator,
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
'per_page': get_paginate_count(request)
}
RequestConfig(request, paginate).configure(circuits_table)
@@ -73,14 +74,18 @@ class ProviderBulkImportView(BulkImportView):
class ProviderBulkEditView(BulkEditView):
queryset = Provider.objects.annotate(count_circuits=Count('circuits')).order_by(*Provider._meta.ordering)
queryset = Provider.objects.annotate(
count_circuits=get_subquery(Circuit, 'provider')
)
filterset = filters.ProviderFilterSet
table = tables.ProviderTable
form = forms.ProviderBulkEditForm
class ProviderBulkDeleteView(BulkDeleteView):
queryset = Provider.objects.annotate(count_circuits=Count('circuits')).order_by(*Provider._meta.ordering)
queryset = Provider.objects.annotate(
count_circuits=get_subquery(Circuit, 'provider')
)
filterset = filters.ProviderFilterSet
table = tables.ProviderTable
@@ -90,7 +95,9 @@ class ProviderBulkDeleteView(BulkDeleteView):
#
class CircuitTypeListView(ObjectListView):
queryset = CircuitType.objects.annotate(circuit_count=Count('circuits')).order_by(*CircuitType._meta.ordering)
queryset = CircuitType.objects.annotate(
circuit_count=get_subquery(Circuit, 'type')
)
table = tables.CircuitTypeTable
@@ -110,7 +117,9 @@ class CircuitTypeBulkImportView(BulkImportView):
class CircuitTypeBulkDeleteView(BulkDeleteView):
queryset = CircuitType.objects.annotate(circuit_count=Count('circuits')).order_by(*CircuitType._meta.ordering)
queryset = CircuitType.objects.annotate(
circuit_count=get_subquery(Circuit, 'type')
)
table = tables.CircuitTypeTable

View File

@@ -2,7 +2,7 @@ import socket
from collections import OrderedDict
from django.conf import settings
from django.db.models import Count, F
from django.db.models import F
from django.http import HttpResponseForbidden, HttpResponse
from django.shortcuts import get_object_or_404
from drf_yasg import openapi
@@ -24,7 +24,7 @@ from dcim.models import (
VirtualChassis,
)
from extras.api.serializers import RenderedGraphSerializer
from extras.api.views import CustomFieldModelViewSet
from extras.api.views import ConfigContextQuerySetMixin, CustomFieldModelViewSet
from extras.models import Graph
from ipam.models import Prefix, VLAN
from utilities.api import (
@@ -109,7 +109,7 @@ class SiteViewSet(CustomFieldModelViewSet):
vlan_count=get_subquery(VLAN, 'site'),
circuit_count=get_subquery(Circuit, 'terminations__site'),
virtualmachine_count=get_subquery(VirtualMachine, 'cluster__site'),
).order_by(*Site._meta.ordering)
)
serializer_class = serializers.SiteSerializer
filterset_class = filters.SiteFilterSet
@@ -146,8 +146,8 @@ class RackGroupViewSet(ModelViewSet):
class RackRoleViewSet(ModelViewSet):
queryset = RackRole.objects.annotate(
rack_count=Count('racks')
).order_by(*RackRole._meta.ordering)
rack_count=get_subquery(Rack, 'role')
)
serializer_class = serializers.RackRoleSerializer
filterset_class = filters.RackRoleFilterSet
@@ -162,7 +162,7 @@ class RackViewSet(CustomFieldModelViewSet):
).annotate(
device_count=get_subquery(Device, 'rack'),
powerfeed_count=get_subquery(PowerFeed, 'rack')
).order_by(*Rack._meta.ordering)
)
serializer_class = serializers.RackSerializer
filterset_class = filters.RackFilterSet
@@ -237,7 +237,7 @@ class ManufacturerViewSet(ModelViewSet):
devicetype_count=get_subquery(DeviceType, 'manufacturer'),
inventoryitem_count=get_subquery(InventoryItem, 'manufacturer'),
platform_count=get_subquery(Platform, 'manufacturer')
).order_by(*Manufacturer._meta.ordering)
)
serializer_class = serializers.ManufacturerSerializer
filterset_class = filters.ManufacturerFilterSet
@@ -248,8 +248,8 @@ class ManufacturerViewSet(ModelViewSet):
class DeviceTypeViewSet(CustomFieldModelViewSet):
queryset = DeviceType.objects.prefetch_related('manufacturer', 'tags').annotate(
device_count=Count('instances')
).order_by(*DeviceType._meta.ordering)
device_count=get_subquery(Device, 'device_type')
)
serializer_class = serializers.DeviceTypeSerializer
filterset_class = filters.DeviceTypeFilterSet
@@ -314,7 +314,7 @@ class DeviceRoleViewSet(ModelViewSet):
queryset = DeviceRole.objects.annotate(
device_count=get_subquery(Device, 'device_role'),
virtualmachine_count=get_subquery(VirtualMachine, 'role')
).order_by(*DeviceRole._meta.ordering)
)
serializer_class = serializers.DeviceRoleSerializer
filterset_class = filters.DeviceRoleFilterSet
@@ -327,7 +327,7 @@ class PlatformViewSet(ModelViewSet):
queryset = Platform.objects.annotate(
device_count=get_subquery(Device, 'platform'),
virtualmachine_count=get_subquery(VirtualMachine, 'platform')
).order_by(*Platform._meta.ordering)
)
serializer_class = serializers.PlatformSerializer
filterset_class = filters.PlatformFilterSet
@@ -336,7 +336,7 @@ class PlatformViewSet(ModelViewSet):
# Devices
#
class DeviceViewSet(CustomFieldModelViewSet):
class DeviceViewSet(CustomFieldModelViewSet, ConfigContextQuerySetMixin):
queryset = Device.objects.prefetch_related(
'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'parent_bay',
'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags',
@@ -396,9 +396,7 @@ class DeviceViewSet(CustomFieldModelViewSet):
if device.platform is None:
raise ServiceUnavailable("No platform is configured for this device.")
if not device.platform.napalm_driver:
raise ServiceUnavailable("No NAPALM driver is configured for this device's platform {}.".format(
device.platform
))
raise ServiceUnavailable(f"No NAPALM driver is configured for this device's platform: {device.platform}.")
# Check for primary IP address from NetBox object
if device.primary_ip:
@@ -407,21 +405,25 @@ class DeviceViewSet(CustomFieldModelViewSet):
# Raise exception for no IP address and no Name if device.name does not exist
if not device.name:
raise ServiceUnavailable(
"This device does not have a primary IP address or device name to lookup configured.")
"This device does not have a primary IP address or device name to lookup configured."
)
try:
# Attempt to complete a DNS name resolution if no primary_ip is set
host = socket.gethostbyname(device.name)
except socket.gaierror:
# Name lookup failure
raise ServiceUnavailable(
f"Name lookup failure, unable to resolve IP address for {device.name}. Please set Primary IP or setup name resolution.")
f"Name lookup failure, unable to resolve IP address for {device.name}. Please set Primary IP or "
f"setup name resolution.")
# Check that NAPALM is installed
try:
import napalm
from napalm.base.exceptions import ModuleImportError
except ImportError:
raise ServiceUnavailable("NAPALM is not installed. Please see the documentation for instructions.")
except ModuleNotFoundError as e:
if getattr(e, 'name') == 'napalm':
raise ServiceUnavailable("NAPALM is not installed. Please see the documentation for instructions.")
raise e
# Validate the configured driver
try:
@@ -617,8 +619,8 @@ class CableViewSet(ModelViewSet):
class VirtualChassisViewSet(ModelViewSet):
queryset = VirtualChassis.objects.prefetch_related('tags').annotate(
member_count=Count('members', distinct=True)
).order_by(*VirtualChassis._meta.ordering)
member_count=get_subquery(Device, 'virtual_chassis')
)
serializer_class = serializers.VirtualChassisSerializer
filterset_class = filters.VirtualChassisFilterSet
@@ -631,8 +633,8 @@ class PowerPanelViewSet(ModelViewSet):
queryset = PowerPanel.objects.prefetch_related(
'site', 'rack_group'
).annotate(
powerfeed_count=Count('powerfeeds')
).order_by(*PowerPanel._meta.ordering)
powerfeed_count=get_subquery(PowerFeed, 'power_panel')
)
serializer_class = serializers.PowerPanelSerializer
filterset_class = filters.PowerPanelFilterSet

View File

@@ -278,6 +278,16 @@ class PowerPortTypeChoices(ChoiceSet):
TYPE_ITA_M = 'ita-m'
TYPE_ITA_N = 'ita-n'
TYPE_ITA_O = 'ita-o'
# USB
TYPE_USB_A = 'usb-a'
TYPE_USB_B = 'usb-b'
TYPE_USB_C = 'usb-c'
TYPE_USB_MINI_A = 'usb-mini-a'
TYPE_USB_MINI_B = 'usb-mini-b'
TYPE_USB_MICRO_A = 'usb-micro-a'
TYPE_USB_MICRO_B = 'usb-micro-b'
TYPE_USB_3_B = 'usb-3-b'
TYPE_USB_3_MICROB = 'usb-3-micro-b'
CHOICES = (
('IEC 60320', (
@@ -367,6 +377,17 @@ class PowerPortTypeChoices(ChoiceSet):
(TYPE_ITA_N, 'ITA Type N'),
(TYPE_ITA_O, 'ITA Type O'),
)),
('USB', (
(TYPE_USB_A, 'USB Type A'),
(TYPE_USB_B, 'USB Type B'),
(TYPE_USB_C, 'USB Type C'),
(TYPE_USB_MINI_A, 'USB Mini A'),
(TYPE_USB_MINI_B, 'USB Mini B'),
(TYPE_USB_MICRO_A, 'USB Micro A'),
(TYPE_USB_MICRO_B, 'USB Micro B'),
(TYPE_USB_3_B, 'USB 3.0 Type B'),
(TYPE_USB_3_MICROB, 'USB 3.0 Micro B'),
)),
)
@@ -456,6 +477,10 @@ class PowerOutletTypeChoices(ChoiceSet):
TYPE_ITA_M = 'ita-m'
TYPE_ITA_N = 'ita-n'
TYPE_ITA_O = 'ita-o'
# USB
TYPE_USB_A = 'usb-a'
TYPE_USB_MICROB = 'usb-micro-b'
TYPE_USB_C = 'usb-c'
# Proprietary
TYPE_HDOT_CX = 'hdot-cx'
@@ -546,6 +571,11 @@ class PowerOutletTypeChoices(ChoiceSet):
(TYPE_ITA_N, 'ITA Type N'),
(TYPE_ITA_O, 'ITA Type O'),
)),
('USB', (
(TYPE_USB_A, 'USB Type A'),
(TYPE_USB_MICROB, 'USB Micro B'),
(TYPE_USB_C, 'USB Type C'),
)),
('Proprietary', (
(TYPE_HDOT_CX, 'HDOT Cx'),
)),
@@ -814,6 +844,9 @@ class InterfaceModeChoices(ChoiceSet):
class PortTypeChoices(ChoiceSet):
TYPE_8P8C = '8p8c'
TYPE_8P6C = '8p6c'
TYPE_8P4C = '8p4c'
TYPE_8P2C = '8p2c'
TYPE_110_PUNCH = '110-punch'
TYPE_BNC = 'bnc'
TYPE_MRJ21 = 'mrj21'
@@ -827,12 +860,18 @@ class PortTypeChoices(ChoiceSet):
TYPE_MPO = 'mpo'
TYPE_LSH = 'lsh'
TYPE_LSH_APC = 'lsh-apc'
TYPE_SPLICE = 'splice'
TYPE_CS = 'cs'
TYPE_SN = 'sn'
CHOICES = (
(
'Copper',
(
(TYPE_8P8C, '8P8C'),
(TYPE_8P6C, '8P6C'),
(TYPE_8P4C, '8P4C'),
(TYPE_8P2C, '8P2C'),
(TYPE_110_PUNCH, '110 Punch'),
(TYPE_BNC, 'BNC'),
(TYPE_MRJ21, 'MRJ21'),
@@ -851,6 +890,9 @@ class PortTypeChoices(ChoiceSet):
(TYPE_SC, 'SC'),
(TYPE_SC_APC, 'SC/APC'),
(TYPE_ST, 'ST'),
(TYPE_CS, 'CS'),
(TYPE_SN, 'SN'),
(TYPE_SPLICE, 'Splice'),
)
)
)

View File

@@ -18,7 +18,7 @@ RACK_ELEVATION_LEGEND_WIDTH_DEFAULT = 30
#
REARPORT_POSITIONS_MIN = 1
REARPORT_POSITIONS_MAX = 64
REARPORT_POSITIONS_MAX = 1024
#

View File

@@ -94,8 +94,12 @@ class RackElevationSVG:
# Embed front device type image if one exists
if self.include_images and device.device_type.front_image:
url = '{}{}'.format(self.base_url, device.device_type.front_image.url)
image = drawing.image(href=url, insert=start, size=end, class_='device-image')
image = drawing.image(
href=device.device_type.front_image.url,
insert=start,
size=end,
class_='device-image'
)
image.fit(scale='slice')
link.add(image)
@@ -107,8 +111,12 @@ class RackElevationSVG:
# Embed rear device type image if one exists
if self.include_images and device.device_type.rear_image:
url = device.device_type.rear_image.url
image = drawing.image(href=url, insert=start, size=end, class_='device-image')
image = drawing.image(
href=device.device_type.rear_image.url,
insert=start,
size=end,
class_='device-image'
)
image.fit(scale='slice')
drawing.add(image)
@@ -141,7 +149,7 @@ class RackElevationSVG:
unit_cursor = 0
for u in elevation:
o = other[unit_cursor]
if not u['device'] and o['device']:
if not u['device'] and o['device'] and o['device'].device_type.is_full_depth:
u['device'] = o['device']
u['height'] = 1
unit_cursor += u.get('height', 1)

View File

@@ -662,16 +662,10 @@ class DeviceFilterSet(
).distinct()
def _has_primary_ip(self, queryset, name, value):
params = Q(primary_ip4__isnull=False) | Q(primary_ip6__isnull=False)
if value:
return queryset.filter(
Q(primary_ip4__isnull=False) |
Q(primary_ip6__isnull=False)
)
else:
return queryset.exclude(
Q(primary_ip4__isnull=False) |
Q(primary_ip6__isnull=False)
)
return queryset.filter(params)
return queryset.exclude(params)
def _virtual_chassis_member(self, queryset, name, value):
return queryset.exclude(virtual_chassis__isnull=value)

View File

@@ -352,8 +352,18 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
#
class RackGroupForm(BootstrapMixin, forms.ModelForm):
region = DynamicModelChoiceField(
queryset=Region.objects.all(),
required=False,
initial_params={
'sites': '$site'
}
)
site = DynamicModelChoiceField(
queryset=Site.objects.all()
queryset=Site.objects.all(),
query_params={
'region_id': '$region'
}
)
parent = DynamicModelChoiceField(
queryset=RackGroup.objects.all(),
@@ -367,7 +377,7 @@ class RackGroupForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = RackGroup
fields = (
'site', 'parent', 'name', 'slug', 'description',
'region', 'site', 'parent', 'name', 'slug', 'description',
)
@@ -447,8 +457,18 @@ class RackRoleCSVForm(CSVModelForm):
#
class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
region = DynamicModelChoiceField(
queryset=Region.objects.all(),
required=False,
initial_params={
'sites': '$site'
}
)
site = DynamicModelChoiceField(
queryset=Site.objects.all()
queryset=Site.objects.all(),
query_params={
'region_id': '$region'
}
)
group = DynamicModelChoiceField(
queryset=RackGroup.objects.all(),
@@ -470,8 +490,9 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
class Meta:
model = Rack
fields = [
'site', 'group', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial', 'asset_tag',
'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'comments', 'tags',
'region', 'site', 'group', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial',
'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit',
'comments', 'tags',
]
help_texts = {
'site': "The site at which the rack exists",
@@ -548,9 +569,19 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
queryset=Rack.objects.all(),
widget=forms.MultipleHiddenInput
)
region = DynamicModelChoiceField(
queryset=Region.objects.all(),
required=False,
initial_params={
'sites': '$site'
}
)
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False
required=False,
query_params={
'region_id': '$region'
}
)
group = DynamicModelChoiceField(
queryset=RackGroup.objects.all(),
@@ -691,9 +722,19 @@ class RackElevationFilterForm(RackFilterForm):
#
class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm):
region = DynamicModelChoiceField(
queryset=Region.objects.all(),
required=False,
initial_params={
'sites': '$site'
}
)
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False
required=False,
query_params={
'region_id': '$region'
}
)
rack_group = DynamicModelChoiceField(
queryset=RackGroup.objects.all(),
@@ -707,7 +748,7 @@ class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm):
display_field='display_name',
query_params={
'site_id': '$site',
'group_id': 'rack',
'group_id': '$rack_group',
}
)
units = NumericArrayField(
@@ -809,15 +850,23 @@ class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditFor
class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm):
model = RackReservation
field_order = ['q', 'site', 'group_id', 'tenant_group', 'tenant']
field_order = ['q', 'region', 'site', 'group_id', 'tenant_group', 'tenant']
q = forms.CharField(
required=False,
label='Search'
)
region = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
required=False
)
site = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
required=False
required=False,
query_params={
'region': '$region'
}
)
group_id = DynamicModelMultipleChoiceField(
queryset=RackGroup.objects.prefetch_related('site'),
@@ -873,7 +922,14 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm):
'front_image', 'rear_image', 'comments', 'tags',
]
widgets = {
'subdevice_role': StaticSelect2()
'subdevice_role': StaticSelect2(),
# Exclude SVG images (unsupported by PIL)
'front_image': forms.FileInput(attrs={
'accept': 'image/bmp,image/gif,image/jpeg,image/png,image/tiff'
}),
'rear_image': forms.FileInput(attrs={
'accept': 'image/bmp,image/gif,image/jpeg,image/png,image/tiff'
})
}
@@ -985,7 +1041,10 @@ class ComponentTemplateCreateForm(ComponentForm):
"""
manufacturer = DynamicModelChoiceField(
queryset=Manufacturer.objects.all(),
required=False
required=False,
initial_params={
'device_types': 'device_type'
}
)
device_type = DynamicModelChoiceField(
queryset=DeviceType.objects.all(),
@@ -1672,7 +1731,10 @@ class PlatformCSVForm(CSVModelForm):
class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
region = DynamicModelChoiceField(
queryset=Region.objects.all(),
required=False
required=False,
initial_params={
'sites': '$site'
}
)
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
@@ -1680,12 +1742,24 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
'region_id': '$region'
}
)
rack_group = DynamicModelChoiceField(
queryset=RackGroup.objects.all(),
required=False,
display_field='display_name',
query_params={
'site_id': '$site'
},
initial_params={
'racks': '$rack'
}
)
rack = DynamicModelChoiceField(
queryset=Rack.objects.all(),
required=False,
display_field='display_name',
query_params={
'site_id': '$site'
'site_id': '$site',
'group_id': '$rack_group',
}
)
position = forms.TypedChoiceField(
@@ -1702,7 +1776,10 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
)
manufacturer = DynamicModelChoiceField(
queryset=Manufacturer.objects.all(),
required=False
required=False,
initial_params={
'device_types': '$device_type'
}
)
device_type = DynamicModelChoiceField(
queryset=DeviceType.objects.all(),
@@ -1724,7 +1801,10 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
cluster_group = DynamicModelChoiceField(
queryset=ClusterGroup.objects.all(),
required=False,
null_option='None'
null_option='None',
initial_params={
'clusters': '$cluster'
}
)
cluster = DynamicModelChoiceField(
queryset=Cluster.objects.all(),
@@ -1763,27 +1843,6 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
}
def __init__(self, *args, **kwargs):
# Initialize helper selectors
instance = kwargs.get('instance')
if 'initial' not in kwargs:
kwargs['initial'] = {}
# Using hasattr() instead of "is not None" to avoid RelatedObjectDoesNotExist on required field
if instance and hasattr(instance, 'device_type'):
kwargs['initial']['manufacturer'] = instance.device_type.manufacturer
if instance and instance.cluster is not None:
kwargs['initial']['cluster_group'] = instance.cluster.group
if 'device_type' in kwargs['initial'] and 'manufacturer' not in kwargs['initial']:
device_type_id = kwargs['initial']['device_type']
manufacturer_id = DeviceType.objects.filter(pk=device_type_id).values_list('manufacturer__pk', flat=True).first()
kwargs['initial']['manufacturer'] = manufacturer_id
if 'cluster' in kwargs['initial'] and 'cluster_group' not in kwargs['initial']:
cluster_id = kwargs['initial']['cluster']
cluster_group_id = Cluster.objects.filter(pk=cluster_id).values_list('group__pk', flat=True).first()
kwargs['initial']['cluster_group'] = cluster_group_id
super().__init__(*args, **kwargs)
if self.instance.pk:
@@ -1811,7 +1870,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
nat_inside__assigned_object_id__in=interface_ids
).prefetch_related('assigned_object')
if nat_ips:
ip_list = [(ip.id, f'{ip.address} ({ip.assigned_object})') for ip in nat_ips]
ip_list = [(ip.id, f'{ip.address} (NAT)') for ip in nat_ips]
ip_choices.append(('NAT IPs', ip_list))
self.fields['primary_ip{}'.format(family)].choices = ip_choices
@@ -2317,7 +2376,7 @@ class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = ConsoleServerPort
fields = [
'device', 'name', 'type', 'description', 'tags',
'device', 'name', 'label', 'type', 'description', 'tags',
]
widgets = {
'device': forms.HiddenInput(),
@@ -2390,7 +2449,7 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = PowerPort
fields = [
'device', 'name', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags',
'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags',
]
widgets = {
'device': forms.HiddenInput(),
@@ -2479,7 +2538,7 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = PowerOutlet
fields = [
'device', 'name', 'type', 'power_port', 'feed_leg', 'description', 'tags',
'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'tags',
]
widgets = {
'device': forms.HiddenInput(),
@@ -2682,11 +2741,14 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm):
else:
device = self.instance.device
# Limit LAG choices to interfaces belonging to this device (or VC master)
# Limit LAG choices to interfaces belonging to this device or a peer VC member
device_query = Q(device=device)
if device.virtual_chassis:
device_query |= Q(device__virtual_chassis=device.virtual_chassis)
self.fields['lag'].queryset = Interface.objects.filter(
device__in=[device, device.get_vc_master()],
device_query,
type=InterfaceTypeChoices.TYPE_LAG
)
).exclude(pk=self.instance.pk)
# Add current site to VLANs query params
self.fields['untagged_vlan'].widget.add_query_param('site_id', device.site.pk)
@@ -2754,14 +2816,14 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Limit LAG choices to interfaces which belong to the parent device (or VC master)
# Limit LAG choices to interfaces belonging to this device or a peer VC member
device = Device.objects.get(
pk=self.initial.get('device') or self.data.get('device')
)
self.fields['lag'].queryset = Interface.objects.filter(
device__in=[device, device.get_vc_master()],
type=InterfaceTypeChoices.TYPE_LAG
)
device_query = Q(device=device)
if device.virtual_chassis:
device_query |= Q(device__virtual_chassis=device.virtual_chassis)
self.fields['lag'].queryset = Interface.objects.filter(device_query, type=InterfaceTypeChoices.TYPE_LAG)
# Add current site to VLANs query params
self.fields['untagged_vlan'].widget.add_query_param('site_id', device.site.pk)
@@ -2832,6 +2894,24 @@ class InterfaceBulkEditForm(
self.fields['untagged_vlan'].widget.add_query_param('site_id', device.site.pk)
self.fields['tagged_vlans'].widget.add_query_param('site_id', device.site.pk)
else:
# See 4523
if 'pk' in self.initial:
site = None
interfaces = Interface.objects.filter(pk__in=self.initial['pk']).prefetch_related('device__site')
# Check interface sites. First interface should set site, further interfaces will either continue the
# loop or reset back to no site and break the loop.
for interface in interfaces:
if site is None:
site = interface.device.site
elif interface.device.site is not site:
site = None
break
if site is not None:
self.fields['untagged_vlan'].widget.add_query_param('site_id', site.pk)
self.fields['tagged_vlans'].widget.add_query_param('site_id', site.pk)
self.fields['lag'].choices = ()
self.fields['lag'].widget.attrs['disabled'] = True
@@ -2876,17 +2956,22 @@ class InterfaceCSVForm(CSVModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Limit LAG choices to interfaces belonging to this device (or VC master)
# 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:
if device and device.virtual_chassis:
self.fields['lag'].queryset = Interface.objects.filter(
device__in=[device, device.get_vc_master()], type=InterfaceTypeChoices.TYPE_LAG
Q(device=device) | Q(device__virtual_chassis=device.virtual_chassis),
type=InterfaceTypeChoices.TYPE_LAG
)
elif device:
self.fields['lag'].queryset = Interface.objects.filter(
device=device,
type=InterfaceTypeChoices.TYPE_LAG
)
else:
self.fields['lag'].queryset = Interface.objects.none()
@@ -3391,10 +3476,18 @@ class ConnectCableToDeviceForm(BootstrapMixin, forms.ModelForm):
"""
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 = DynamicModelChoiceField(
queryset=Site.objects.all(),
label='Site',
required=False
required=False,
query_params={
'region_id': '$termination_b_region'
}
)
termination_b_rack = DynamicModelChoiceField(
queryset=Rack.objects.all(),
@@ -3416,12 +3509,16 @@ class ConnectCableToDeviceForm(BootstrapMixin, forms.ModelForm):
'rack_id': '$termination_b_rack',
}
)
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta:
model = Cable
fields = [
'termination_b_site', 'termination_b_rack', 'termination_b_device', 'termination_b_id', 'type', 'status',
'label', 'color', 'length', 'length_unit',
'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': StaticSelect2,
@@ -3518,10 +3615,18 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, forms.ModelForm):
label='Provider',
required=False
)
termination_b_region = DynamicModelChoiceField(
queryset=Region.objects.all(),
label='Region',
required=False
)
termination_b_site = DynamicModelChoiceField(
queryset=Site.objects.all(),
label='Site',
required=False
required=False,
query_params={
'region_id': '$termination_b_region'
}
)
termination_b_circuit = DynamicModelChoiceField(
queryset=Circuit.objects.all(),
@@ -3541,12 +3646,16 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, forms.ModelForm):
'circuit_id': '$termination_b_circuit'
}
)
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta:
model = Cable
fields = [
'termination_b_provider', 'termination_b_site', 'termination_b_circuit', 'termination_b_id', 'type',
'status', 'label', 'color', 'length', 'length_unit',
'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):
@@ -3555,11 +3664,18 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, forms.ModelForm):
class ConnectCableToPowerFeedForm(BootstrapMixin, forms.ModelForm):
termination_b_region = DynamicModelChoiceField(
queryset=Region.objects.all(),
label='Region',
required=False
)
termination_b_site = DynamicModelChoiceField(
queryset=Site.objects.all(),
label='Site',
required=False,
display_field='cid'
query_params={
'region_id': '$termination_b_region'
}
)
termination_b_rackgroup = DynamicModelChoiceField(
queryset=RackGroup.objects.all(),
@@ -3587,12 +3703,16 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, forms.ModelForm):
'power_panel_id': '$termination_b_powerpanel'
}
)
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta:
model = Cable
fields = [
'termination_b_rackgroup', 'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'label',
'color', 'length', 'length_unit',
'color', 'length', 'length_unit', 'tags',
]
def clean_termination_b_id(self):
@@ -3801,10 +3921,18 @@ class CableFilterForm(BootstrapMixin, forms.Form):
required=False,
label='Search'
)
region = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
required=False
)
site = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
required=False
required=False,
query_params={
'region': '$region'
}
)
tenant = DynamicModelMultipleChoiceField(
queryset=Tenant.objects.all(),
@@ -3853,10 +3981,18 @@ class CableFilterForm(BootstrapMixin, forms.Form):
#
class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form):
region = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
required=False
)
site = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
required=False
required=False,
query_params={
'region': '$region'
}
)
device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
@@ -3869,10 +4005,18 @@ class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form):
class PowerConnectionFilterForm(BootstrapMixin, forms.Form):
region = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
required=False
)
site = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
required=False
required=False,
query_params={
'region': '$region'
}
)
device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
@@ -3885,10 +4029,18 @@ class PowerConnectionFilterForm(BootstrapMixin, forms.Form):
class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form):
region = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
required=False
)
site = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
required=False
required=False,
query_params={
'region': '$region'
}
)
device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
@@ -3912,9 +4064,19 @@ class DeviceSelectionForm(forms.Form):
class VirtualChassisCreateForm(BootstrapMixin, forms.ModelForm):
region = DynamicModelChoiceField(
queryset=Region.objects.all(),
required=False,
initial_params={
'sites': '$site'
}
)
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False
required=False,
query_params={
'region_id': '$region'
}
)
rack = DynamicModelChoiceField(
queryset=Rack.objects.all(),
@@ -3928,6 +4090,7 @@ class VirtualChassisCreateForm(BootstrapMixin, forms.ModelForm):
members = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
required=False,
display_field='display_name',
query_params={
'site_id': '$site',
'rack_id': '$rack',
@@ -3946,7 +4109,7 @@ class VirtualChassisCreateForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = VirtualChassis
fields = [
'name', 'domain', 'site', 'rack', 'members', 'initial_position', 'tags',
'name', 'domain', 'region', 'site', 'rack', 'members', 'initial_position', 'tags',
]
def save(self, *args, **kwargs):
@@ -4043,9 +4206,19 @@ class DeviceVCMembershipForm(forms.ModelForm):
class VCMemberSelectForm(BootstrapMixin, forms.Form):
region = DynamicModelChoiceField(
queryset=Region.objects.all(),
required=False,
initial_params={
'sites': '$site'
}
)
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False
required=False,
query_params={
'region_id': '$region'
}
)
rack = DynamicModelChoiceField(
queryset=Rack.objects.all(),
@@ -4144,8 +4317,18 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm):
#
class PowerPanelForm(BootstrapMixin, forms.ModelForm):
region = DynamicModelChoiceField(
queryset=Region.objects.all(),
required=False,
initial_params={
'sites': '$site'
}
)
site = DynamicModelChoiceField(
queryset=Site.objects.all()
queryset=Site.objects.all(),
query_params={
'region_id': '$region'
}
)
rack_group = DynamicModelChoiceField(
queryset=RackGroup.objects.all(),
@@ -4162,7 +4345,7 @@ class PowerPanelForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = PowerPanel
fields = [
'site', 'rack_group', 'name', 'tags',
'region', 'site', 'rack_group', 'name', 'tags',
]
@@ -4197,9 +4380,19 @@ class PowerPanelBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
queryset=PowerPanel.objects.all(),
widget=forms.MultipleHiddenInput
)
region = DynamicModelChoiceField(
queryset=Region.objects.all(),
required=False,
initial_params={
'sites': '$site'
}
)
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False
required=False,
query_params={
'region_id': '$region'
}
)
rack_group = DynamicModelChoiceField(
queryset=RackGroup.objects.all(),
@@ -4251,9 +4444,22 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm):
#
class PowerFeedForm(BootstrapMixin, CustomFieldModelForm):
region = DynamicModelChoiceField(
queryset=Region.objects.all(),
required=False,
initial_params={
'sites__powerpanel': '$power_panel'
}
)
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False
required=False,
initial_params={
'powerpanel': '$power_panel'
},
query_params={
'region_id': '$region'
}
)
power_panel = DynamicModelChoiceField(
queryset=PowerPanel.objects.all(),
@@ -4278,7 +4484,7 @@ class PowerFeedForm(BootstrapMixin, CustomFieldModelForm):
class Meta:
model = PowerFeed
fields = [
'site', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage',
'region', 'site', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage',
'max_utilization', 'comments', 'tags',
]
widgets = {
@@ -4288,14 +4494,6 @@ class PowerFeedForm(BootstrapMixin, CustomFieldModelForm):
'phase': StaticSelect2(),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Initialize site field
if self.instance and hasattr(self.instance, 'power_panel'):
self.initial['site'] = self.instance.power_panel.site
class PowerFeedCSVForm(CustomFieldModelCSVForm):
site = CSVModelChoiceField(

View File

@@ -0,0 +1,17 @@
# Generated by Django 3.1 on 2020-08-24 16:03
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('dcim', '0114_update_jsonfield'),
]
operations = [
migrations.AlterModelOptions(
name='rackreservation',
options={'ordering': ['created', 'pk']},
),
]

View File

@@ -0,0 +1,34 @@
# Generated by Django 3.1 on 2020-09-16 16:51
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0115_rackreservation_order'),
]
operations = [
migrations.AlterField(
model_name='frontport',
name='rear_port_position',
field=models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(1024)]),
),
migrations.AlterField(
model_name='frontporttemplate',
name='rear_port_position',
field=models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(1024)]),
),
migrations.AlterField(
model_name='rearport',
name='positions',
field=models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(1024)]),
),
migrations.AlterField(
model_name='rearporttemplate',
name='positions',
field=models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(1024)]),
),
]

File diff suppressed because it is too large Load Diff

View File

@@ -264,7 +264,10 @@ class FrontPortTemplate(ComponentTemplateModel):
)
rear_port_position = models.PositiveSmallIntegerField(
default=1,
validators=[MinValueValidator(1), MaxValueValidator(64)]
validators=[
MinValueValidator(REARPORT_POSITIONS_MIN),
MaxValueValidator(REARPORT_POSITIONS_MAX)
]
)
class Meta:
@@ -315,7 +318,10 @@ class RearPortTemplate(ComponentTemplateModel):
)
positions = models.PositiveSmallIntegerField(
default=1,
validators=[MinValueValidator(1), MaxValueValidator(64)]
validators=[
MinValueValidator(REARPORT_POSITIONS_MIN),
MaxValueValidator(REARPORT_POSITIONS_MAX)
]
)
class Meta:

View File

@@ -22,6 +22,7 @@ from utilities.utils import serialize_object
__all__ = (
'BaseInterface',
'CableTermination',
'ConsolePort',
'ConsoleServerPort',
@@ -404,6 +405,14 @@ class PowerPort(CableTermination, ComponentModel):
self.description,
)
def clean(self):
if self.maximum_draw is not None and self.allocated_draw is not None:
if self.allocated_draw > self.maximum_draw:
raise ValidationError({
'allocated_draw': f"Allocated draw cannot exceed the maximum draw ({self.maximum_draw}W)."
})
@property
def connected_endpoint(self):
"""
@@ -580,6 +589,18 @@ class BaseInterface(models.Model):
class Meta:
abstract = True
def save(self, *args, **kwargs):
# Remove untagged VLAN assignment for non-802.1Q interfaces
if not self.mode:
self.untagged_vlan = None
# Only "tagged" interfaces may have tagged VLANs assigned. ("tagged all" implies all VLANs are assigned.)
if self.pk and self.mode != InterfaceModeChoices.MODE_TAGGED:
self.tagged_vlans.clear()
return super().save(*args, **kwargs)
@extras_features('graphs', 'export_templates', 'webhooks')
class Interface(CableTermination, ComponentModel, BaseInterface):
@@ -688,27 +709,25 @@ class Interface(CableTermination, ComponentModel, BaseInterface):
"Disconnect the interface or choose a suitable type."
})
# An interface's LAG must belong to the same device (or VC master)
if self.lag and self.lag.device not in [self.device, self.device.get_vc_master()]:
raise ValidationError({
'lag': "The selected LAG interface ({}) belongs to a different device ({}).".format(
self.lag.name, self.lag.device.name
)
})
# An interface's LAG must belong to the same device or virtual chassis
if self.lag and self.lag.device != self.device:
if self.device.virtual_chassis is None:
raise ValidationError({
'lag': f"The selected LAG interface ({self.lag}) belongs to a different device ({self.lag.device})."
})
elif self.lag.device.virtual_chassis != self.device.virtual_chassis:
raise ValidationError({
'lag': f"The selected LAG interface ({self.lag}) belongs to {self.lag.device}, which is not part "
f"of virtual chassis {self.device.virtual_chassis}."
})
# A virtual interface cannot have a parent LAG
if self.type in NONCONNECTABLE_IFACE_TYPES and self.lag is not None:
raise ValidationError({
'lag': "{} interfaces cannot have a parent LAG interface.".format(self.get_type_display())
})
if self.type == InterfaceTypeChoices.TYPE_VIRTUAL and self.lag is not None:
raise ValidationError({'lag': "Virtual interfaces cannot have a parent LAG interface."})
# Only a LAG can have LAG members
if self.type != InterfaceTypeChoices.TYPE_LAG and self.member_interfaces.exists():
raise ValidationError({
'type': "Cannot change interface type; it has LAG members ({}).".format(
", ".join([iface.name for iface in self.member_interfaces.all()])
)
})
# A LAG interface cannot be its own parent
if self.pk and self.lag_id == self.pk:
raise ValidationError({'lag': "A LAG interface cannot be its own parent."})
# Validate untagged VLAN
if self.untagged_vlan and self.untagged_vlan.site not in [self.parent.site, None]:
@@ -717,18 +736,6 @@ class Interface(CableTermination, ComponentModel, BaseInterface):
"device, or it must be global".format(self.untagged_vlan)
})
def save(self, *args, **kwargs):
# Remove untagged VLAN assignment for non-802.1Q interfaces
if self.mode is None:
self.untagged_vlan = None
# Only "tagged" interfaces may have tagged VLANs assigned. ("tagged all" implies all VLANs are assigned.)
if self.pk and self.mode != InterfaceModeChoices.MODE_TAGGED:
self.tagged_vlans.clear()
return super().save(*args, **kwargs)
@property
def connected_endpoint(self):
"""
@@ -810,7 +817,10 @@ class FrontPort(CableTermination, ComponentModel):
)
rear_port_position = models.PositiveSmallIntegerField(
default=1,
validators=[MinValueValidator(1), MaxValueValidator(64)]
validators=[
MinValueValidator(REARPORT_POSITIONS_MIN),
MaxValueValidator(REARPORT_POSITIONS_MAX)
]
)
tags = TaggableManager(through=TaggedItem)
@@ -841,17 +851,16 @@ class FrontPort(CableTermination, ComponentModel):
# Validate rear port assignment
if self.rear_port.device != self.device:
raise ValidationError(
"Rear port ({}) must belong to the same device".format(self.rear_port)
)
raise ValidationError({
"rear_port": f"Rear port ({self.rear_port}) must belong to the same device"
})
# Validate rear port position assignment
if self.rear_port_position > self.rear_port.positions:
raise ValidationError(
"Invalid rear port position ({}); rear port {} has only {} positions".format(
self.rear_port_position, self.rear_port.name, self.rear_port.positions
)
)
raise ValidationError({
"rear_port_position": f"Invalid rear port position ({self.rear_port_position}): Rear port "
f"{self.rear_port.name} has only {self.rear_port.positions} positions"
})
@extras_features('webhooks')
@@ -865,7 +874,10 @@ class RearPort(CableTermination, ComponentModel):
)
positions = models.PositiveSmallIntegerField(
default=1,
validators=[MinValueValidator(1), MaxValueValidator(64)]
validators=[
MinValueValidator(REARPORT_POSITIONS_MIN),
MaxValueValidator(REARPORT_POSITIONS_MAX)
]
)
tags = TaggableManager(through=TaggedItem)
@@ -878,6 +890,16 @@ class RearPort(CableTermination, ComponentModel):
def get_absolute_url(self):
return reverse('dcim:rearport', kwargs={'pk': self.pk})
def clean(self):
# Check that positions count is greater than or equal to the number of associated FrontPorts
frontport_count = self.frontports.count()
if self.positions < frontport_count:
raise ValidationError({
"positions": f"The number of positions cannot be less than the number of mapped front ports "
f"({frontport_count})"
})
def to_csv(self):
return (
self.device.identifier,

File diff suppressed because it is too large Load Diff

237
netbox/dcim/models/power.py Normal file
View File

@@ -0,0 +1,237 @@
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
from django.urls import reverse
from taggit.managers import TaggableManager
from dcim.choices import *
from dcim.constants import *
from extras.models import ChangeLoggedModel, CustomFieldModel, TaggedItem
from extras.utils import extras_features
from utilities.querysets import RestrictedQuerySet
from utilities.validators import ExclusionValidator
from .device_components import CableTermination
__all__ = (
'PowerFeed',
'PowerPanel',
)
#
# Power
#
@extras_features('custom_links', 'export_templates', 'webhooks')
class PowerPanel(ChangeLoggedModel):
"""
A distribution point for electrical power; e.g. a data center RPP.
"""
site = models.ForeignKey(
to='Site',
on_delete=models.PROTECT
)
rack_group = models.ForeignKey(
to='RackGroup',
on_delete=models.PROTECT,
blank=True,
null=True
)
name = models.CharField(
max_length=50
)
tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = ['site', 'rack_group', 'name']
class Meta:
ordering = ['site', 'name']
unique_together = ['site', 'name']
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('dcim:powerpanel', args=[self.pk])
def to_csv(self):
return (
self.site.name,
self.rack_group.name if self.rack_group else None,
self.name,
)
def clean(self):
# RackGroup must belong to assigned Site
if self.rack_group and self.rack_group.site != self.site:
raise ValidationError("Rack group {} ({}) is in a different site than {}".format(
self.rack_group, self.rack_group.site, self.site
))
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
"""
An electrical circuit delivered from a PowerPanel.
"""
power_panel = models.ForeignKey(
to='PowerPanel',
on_delete=models.PROTECT,
related_name='powerfeeds'
)
rack = models.ForeignKey(
to='Rack',
on_delete=models.PROTECT,
blank=True,
null=True
)
connected_endpoint = models.OneToOneField(
to='dcim.PowerPort',
on_delete=models.SET_NULL,
related_name='+',
blank=True,
null=True
)
connection_status = models.BooleanField(
choices=CONNECTION_STATUS_CHOICES,
blank=True,
null=True
)
name = models.CharField(
max_length=50
)
status = models.CharField(
max_length=50,
choices=PowerFeedStatusChoices,
default=PowerFeedStatusChoices.STATUS_ACTIVE
)
type = models.CharField(
max_length=50,
choices=PowerFeedTypeChoices,
default=PowerFeedTypeChoices.TYPE_PRIMARY
)
supply = models.CharField(
max_length=50,
choices=PowerFeedSupplyChoices,
default=PowerFeedSupplyChoices.SUPPLY_AC
)
phase = models.CharField(
max_length=50,
choices=PowerFeedPhaseChoices,
default=PowerFeedPhaseChoices.PHASE_SINGLE
)
voltage = models.SmallIntegerField(
default=POWERFEED_VOLTAGE_DEFAULT,
validators=[ExclusionValidator([0])]
)
amperage = models.PositiveSmallIntegerField(
validators=[MinValueValidator(1)],
default=POWERFEED_AMPERAGE_DEFAULT
)
max_utilization = models.PositiveSmallIntegerField(
validators=[MinValueValidator(1), MaxValueValidator(100)],
default=POWERFEED_MAX_UTILIZATION_DEFAULT,
help_text="Maximum permissible draw (percentage)"
)
available_power = models.PositiveIntegerField(
default=0,
editable=False
)
comments = models.TextField(
blank=True
)
custom_field_values = GenericRelation(
to='extras.CustomFieldValue',
content_type_field='obj_type',
object_id_field='obj_id'
)
tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = [
'site', 'power_panel', 'rack_group', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage',
'amperage', 'max_utilization', 'comments',
]
clone_fields = [
'power_panel', 'rack', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization',
'available_power',
]
STATUS_CLASS_MAP = {
PowerFeedStatusChoices.STATUS_OFFLINE: 'warning',
PowerFeedStatusChoices.STATUS_ACTIVE: 'success',
PowerFeedStatusChoices.STATUS_PLANNED: 'info',
PowerFeedStatusChoices.STATUS_FAILED: 'danger',
}
TYPE_CLASS_MAP = {
PowerFeedTypeChoices.TYPE_PRIMARY: 'success',
PowerFeedTypeChoices.TYPE_REDUNDANT: 'info',
}
class Meta:
ordering = ['power_panel', 'name']
unique_together = ['power_panel', 'name']
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('dcim:powerfeed', args=[self.pk])
def to_csv(self):
return (
self.power_panel.site.name,
self.power_panel.name,
self.rack.group.name if self.rack and self.rack.group else None,
self.rack.name if self.rack else None,
self.name,
self.get_status_display(),
self.get_type_display(),
self.get_supply_display(),
self.get_phase_display(),
self.voltage,
self.amperage,
self.max_utilization,
self.comments,
)
def clean(self):
# Rack must belong to same Site as PowerPanel
if self.rack and self.rack.site != self.power_panel.site:
raise ValidationError("Rack {} ({}) and power panel {} ({}) are in different sites".format(
self.rack, self.rack.site, self.power_panel, self.power_panel.site
))
# AC voltage cannot be negative
if self.voltage < 0 and self.supply == PowerFeedSupplyChoices.SUPPLY_AC:
raise ValidationError({
"voltage": "Voltage cannot be negative for AC supply"
})
def save(self, *args, **kwargs):
# Cache the available_power property on the instance
kva = abs(self.voltage) * self.amperage * (self.max_utilization / 100)
if self.phase == PowerFeedPhaseChoices.PHASE_3PHASE:
self.available_power = round(kva * 1.732)
else:
self.available_power = round(kva)
super().save(*args, **kwargs)
@property
def parent(self):
return self.power_panel
def get_type_class(self):
return self.TYPE_CLASS_MAP.get(self.type)
def get_status_class(self):
return self.STATUS_CLASS_MAP.get(self.status)

655
netbox/dcim/models/racks.py Normal file
View File

@@ -0,0 +1,655 @@
from collections import OrderedDict
from itertools import count, groupby
from django.conf import settings
from django.contrib.auth.models import User
from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import Count, Sum
from django.urls import reverse
from mptt.models import MPTTModel, TreeForeignKey
from taggit.managers import TaggableManager
from dcim.choices import *
from dcim.constants import *
from dcim.elevations import RackElevationSVG
from extras.models import ChangeLoggedModel, CustomFieldModel, ObjectChange, TaggedItem
from extras.utils import extras_features
from utilities.choices import ColorChoices
from utilities.fields import ColorField, NaturalOrderingField
from utilities.querysets import RestrictedQuerySet
from utilities.mptt import TreeManager
from utilities.utils import serialize_object
from .devices import Device
from .power import PowerFeed
__all__ = (
'Rack',
'RackGroup',
'RackReservation',
'RackRole',
)
#
# Racks
#
@extras_features('export_templates')
class RackGroup(MPTTModel, ChangeLoggedModel):
"""
Racks can be grouped as subsets within a Site. The scope of a group will depend on how Sites are defined. For
example, if a Site spans a corporate campus, a RackGroup might be defined to represent each building within that
campus. If a Site instead represents a single building, a RackGroup might represent a single room or floor.
"""
name = models.CharField(
max_length=50
)
slug = models.SlugField()
site = models.ForeignKey(
to='dcim.Site',
on_delete=models.CASCADE,
related_name='rack_groups'
)
parent = TreeForeignKey(
to='self',
on_delete=models.CASCADE,
related_name='children',
blank=True,
null=True,
db_index=True
)
description = models.CharField(
max_length=200,
blank=True
)
objects = TreeManager()
csv_headers = ['site', 'parent', 'name', 'slug', 'description']
class Meta:
ordering = ['site', 'name']
unique_together = [
['site', 'name'],
['site', 'slug'],
]
class MPTTMeta:
order_insertion_by = ['name']
def __str__(self):
return self.name
def get_absolute_url(self):
return "{}?group_id={}".format(reverse('dcim:rack_list'), self.pk)
def to_csv(self):
return (
self.site,
self.parent.name if self.parent else '',
self.name,
self.slug,
self.description,
)
def to_objectchange(self, action):
# Remove MPTT-internal fields
return ObjectChange(
changed_object=self,
object_repr=str(self),
action=action,
object_data=serialize_object(self, exclude=['level', 'lft', 'rght', 'tree_id'])
)
def clean(self):
# Parent RackGroup (if any) must belong to the same Site
if self.parent and self.parent.site != self.site:
raise ValidationError(f"Parent rack group ({self.parent}) must belong to the same site ({self.site})")
class RackRole(ChangeLoggedModel):
"""
Racks can be organized by functional role, similar to Devices.
"""
name = models.CharField(
max_length=50,
unique=True
)
slug = models.SlugField(
unique=True
)
color = ColorField(
default=ColorChoices.COLOR_GREY
)
description = models.CharField(
max_length=200,
blank=True,
)
objects = RestrictedQuerySet.as_manager()
csv_headers = ['name', 'slug', 'color', 'description']
class Meta:
ordering = ['name']
def __str__(self):
return self.name
def get_absolute_url(self):
return "{}?role={}".format(reverse('dcim:rack_list'), self.slug)
def to_csv(self):
return (
self.name,
self.slug,
self.color,
self.description,
)
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class Rack(ChangeLoggedModel, CustomFieldModel):
"""
Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
Each Rack is assigned to a Site and (optionally) a RackGroup.
"""
name = models.CharField(
max_length=50
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
facility_id = models.CharField(
max_length=50,
blank=True,
null=True,
verbose_name='Facility ID',
help_text='Locally-assigned identifier'
)
site = models.ForeignKey(
to='dcim.Site',
on_delete=models.PROTECT,
related_name='racks'
)
group = models.ForeignKey(
to='dcim.RackGroup',
on_delete=models.SET_NULL,
related_name='racks',
blank=True,
null=True,
help_text='Assigned group'
)
tenant = models.ForeignKey(
to='tenancy.Tenant',
on_delete=models.PROTECT,
related_name='racks',
blank=True,
null=True
)
status = models.CharField(
max_length=50,
choices=RackStatusChoices,
default=RackStatusChoices.STATUS_ACTIVE
)
role = models.ForeignKey(
to='dcim.RackRole',
on_delete=models.PROTECT,
related_name='racks',
blank=True,
null=True,
help_text='Functional role'
)
serial = models.CharField(
max_length=50,
blank=True,
verbose_name='Serial number'
)
asset_tag = models.CharField(
max_length=50,
blank=True,
null=True,
unique=True,
verbose_name='Asset tag',
help_text='A unique tag used to identify this rack'
)
type = models.CharField(
choices=RackTypeChoices,
max_length=50,
blank=True,
verbose_name='Type'
)
width = models.PositiveSmallIntegerField(
choices=RackWidthChoices,
default=RackWidthChoices.WIDTH_19IN,
verbose_name='Width',
help_text='Rail-to-rail width'
)
u_height = models.PositiveSmallIntegerField(
default=RACK_U_HEIGHT_DEFAULT,
verbose_name='Height (U)',
validators=[MinValueValidator(1), MaxValueValidator(100)],
help_text='Height in rack units'
)
desc_units = models.BooleanField(
default=False,
verbose_name='Descending units',
help_text='Units are numbered top-to-bottom'
)
outer_width = models.PositiveSmallIntegerField(
blank=True,
null=True,
help_text='Outer dimension of rack (width)'
)
outer_depth = models.PositiveSmallIntegerField(
blank=True,
null=True,
help_text='Outer dimension of rack (depth)'
)
outer_unit = models.CharField(
max_length=50,
choices=RackDimensionUnitChoices,
blank=True,
)
comments = models.TextField(
blank=True
)
custom_field_values = GenericRelation(
to='extras.CustomFieldValue',
content_type_field='obj_type',
object_id_field='obj_id'
)
images = GenericRelation(
to='extras.ImageAttachment'
)
tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = [
'site', 'group', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag', 'width',
'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'comments',
]
clone_fields = [
'site', 'group', 'tenant', 'status', 'role', 'type', 'width', 'u_height', 'desc_units', 'outer_width',
'outer_depth', 'outer_unit',
]
STATUS_CLASS_MAP = {
RackStatusChoices.STATUS_RESERVED: 'warning',
RackStatusChoices.STATUS_AVAILABLE: 'success',
RackStatusChoices.STATUS_PLANNED: 'info',
RackStatusChoices.STATUS_ACTIVE: 'primary',
RackStatusChoices.STATUS_DEPRECATED: 'danger',
}
class Meta:
ordering = ('site', 'group', '_name', 'pk') # (site, group, name) may be non-unique
unique_together = (
# Name and facility_id must be unique *only* within a RackGroup
('group', 'name'),
('group', 'facility_id'),
)
def __str__(self):
return self.display_name or super().__str__()
def get_absolute_url(self):
return reverse('dcim:rack', args=[self.pk])
def clean(self):
# Validate outer dimensions and unit
if (self.outer_width is not None or self.outer_depth is not None) and not self.outer_unit:
raise ValidationError("Must specify a unit when setting an outer width/depth")
elif self.outer_width is None and self.outer_depth is None:
self.outer_unit = ''
if self.pk:
# Validate that Rack is tall enough to house the installed Devices
top_device = Device.objects.filter(
rack=self
).exclude(
position__isnull=True
).order_by('-position').first()
if top_device:
min_height = top_device.position + top_device.device_type.u_height - 1
if self.u_height < min_height:
raise ValidationError({
'u_height': "Rack must be at least {}U tall to house currently installed devices.".format(
min_height
)
})
# Validate that Rack was assigned a group of its same site, if applicable
if self.group:
if self.group.site != self.site:
raise ValidationError({
'group': "Rack group must be from the same site, {}.".format(self.site)
})
def save(self, *args, **kwargs):
# Record the original site assignment for this rack.
_site_id = None
if self.pk:
_site_id = Rack.objects.get(pk=self.pk).site_id
super().save(*args, **kwargs)
# Update racked devices if the assigned Site has been changed.
if _site_id is not None and self.site_id != _site_id:
devices = Device.objects.filter(rack=self)
for device in devices:
device.site = self.site
device.save()
def to_csv(self):
return (
self.site.name,
self.group.name if self.group else None,
self.name,
self.facility_id,
self.tenant.name if self.tenant else None,
self.get_status_display(),
self.role.name if self.role else None,
self.get_type_display() if self.type else None,
self.serial,
self.asset_tag,
self.width,
self.u_height,
self.desc_units,
self.outer_width,
self.outer_depth,
self.outer_unit,
self.comments,
)
@property
def units(self):
if self.desc_units:
return range(1, self.u_height + 1)
else:
return reversed(range(1, self.u_height + 1))
@property
def display_name(self):
if self.facility_id:
return f'{self.name} ({self.facility_id})'
return self.name
def get_status_class(self):
return self.STATUS_CLASS_MAP.get(self.status)
def get_rack_units(self, user=None, face=DeviceFaceChoices.FACE_FRONT, exclude=None, expand_devices=True):
"""
Return a list of rack units as dictionaries. Example: {'device': None, 'face': 0, 'id': 48, 'name': 'U48'}
Each key 'device' is either a Device or None. By default, multi-U devices are repeated for each U they occupy.
:param face: Rack face (front or rear)
:param user: User instance to be used for evaluating device view permissions. If None, all devices
will be included.
:param exclude: PK of a Device to exclude (optional); helpful when relocating a Device within a Rack
:param expand_devices: When True, all units that a device occupies will be listed with each containing a
reference to the device. When False, only the bottom most unit for a device is included and that unit
contains a height attribute for the device
"""
elevation = OrderedDict()
for u in self.units:
elevation[u] = {
'id': u,
'name': f'U{u}',
'face': face,
'device': None,
'occupied': False
}
# Add devices to rack units list
if self.pk:
# Retrieve all devices installed within the rack
queryset = Device.objects.prefetch_related(
'device_type',
'device_type__manufacturer',
'device_role'
).annotate(
devicebay_count=Count('devicebays')
).exclude(
pk=exclude
).filter(
rack=self,
position__gt=0,
device_type__u_height__gt=0
).filter(
Q(face=face) | Q(device_type__is_full_depth=True)
)
# Determine which devices the user has permission to view
permitted_device_ids = []
if user is not None:
permitted_device_ids = self.devices.restrict(user, 'view').values_list('pk', flat=True)
for device in queryset:
if expand_devices:
for u in range(device.position, device.position + device.device_type.u_height):
if user is None or device.pk in permitted_device_ids:
elevation[u]['device'] = device
elevation[u]['occupied'] = True
else:
if user is None or device.pk in permitted_device_ids:
elevation[device.position]['device'] = device
elevation[device.position]['occupied'] = True
elevation[device.position]['height'] = device.device_type.u_height
for u in range(device.position + 1, device.position + device.device_type.u_height):
elevation.pop(u, None)
return [u for u in elevation.values()]
def get_available_units(self, u_height=1, rack_face=None, exclude=None):
"""
Return a list of units within the rack available to accommodate a device of a given U height (default 1).
Optionally exclude one or more devices when calculating empty units (needed when moving a device from one
position to another within a rack).
:param u_height: Minimum number of contiguous free units required
:param rack_face: The face of the rack (front or rear) required; 'None' if device is full depth
:param exclude: List of devices IDs to exclude (useful when moving a device within a rack)
"""
# Gather all devices which consume U space within the rack
devices = self.devices.prefetch_related('device_type').filter(position__gte=1)
if exclude is not None:
devices = devices.exclude(pk__in=exclude)
# Initialize the rack unit skeleton
units = list(range(1, self.u_height + 1))
# Remove units consumed by installed devices
for d in devices:
if rack_face is None or d.face == rack_face or d.device_type.is_full_depth:
for u in range(d.position, d.position + d.device_type.u_height):
try:
units.remove(u)
except ValueError:
# Found overlapping devices in the rack!
pass
# Remove units without enough space above them to accommodate a device of the specified height
available_units = []
for u in units:
if set(range(u, u + u_height)).issubset(units):
available_units.append(u)
return list(reversed(available_units))
def get_reserved_units(self):
"""
Return a dictionary mapping all reserved units within the rack to their reservation.
"""
reserved_units = {}
for r in self.reservations.all():
for u in r.units:
reserved_units[u] = r
return reserved_units
def get_elevation_svg(
self,
face=DeviceFaceChoices.FACE_FRONT,
user=None,
unit_width=settings.RACK_ELEVATION_DEFAULT_UNIT_WIDTH,
unit_height=settings.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT,
legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT,
include_images=True,
base_url=None
):
"""
Return an SVG of the rack elevation
:param face: Enum of [front, rear] representing the desired side of the rack elevation to render
:param user: User instance to be used for evaluating device view permissions. If None, all devices
will be included.
:param unit_width: Width in pixels for the rendered drawing
:param unit_height: Height of each rack unit for the rendered drawing. Note this is not the total
height of the elevation
:param legend_width: Width of the unit legend, in pixels
:param include_images: Embed front/rear device images where available
:param base_url: Base URL for links and images. If none, URLs will be relative.
"""
elevation = RackElevationSVG(self, user=user, include_images=include_images, base_url=base_url)
return elevation.render(face, unit_width, unit_height, legend_width)
def get_0u_devices(self):
return self.devices.filter(position=0)
def get_utilization(self):
"""
Determine the utilization rate of the rack and return it as a percentage. Occupied and reserved units both count
as utilized.
"""
# Determine unoccupied units
available_units = self.get_available_units()
# Remove reserved units
for u in self.get_reserved_units():
if u in available_units:
available_units.remove(u)
occupied_unit_count = self.u_height - len(available_units)
percentage = int(float(occupied_unit_count) / self.u_height * 100)
return percentage
def get_power_utilization(self):
"""
Determine the utilization rate of power in the rack and return it as a percentage.
"""
power_stats = PowerFeed.objects.filter(
rack=self
).annotate(
allocated_draw_total=Sum('connected_endpoint__poweroutlets__connected_endpoint__allocated_draw'),
).values(
'allocated_draw_total',
'available_power'
)
if power_stats:
allocated_draw_total = sum(x['allocated_draw_total'] or 0 for x in power_stats)
available_power_total = sum(x['available_power'] for x in power_stats)
return int(allocated_draw_total / available_power_total * 100) or 0
return 0
@extras_features('custom_links', 'export_templates', 'webhooks')
class RackReservation(ChangeLoggedModel):
"""
One or more reserved units within a Rack.
"""
rack = models.ForeignKey(
to='dcim.Rack',
on_delete=models.CASCADE,
related_name='reservations'
)
units = ArrayField(
base_field=models.PositiveSmallIntegerField()
)
tenant = models.ForeignKey(
to='tenancy.Tenant',
on_delete=models.PROTECT,
related_name='rackreservations',
blank=True,
null=True
)
user = models.ForeignKey(
to=User,
on_delete=models.PROTECT
)
description = models.CharField(
max_length=200
)
tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = ['site', 'rack_group', 'rack', 'units', 'tenant', 'user', 'description']
class Meta:
ordering = ['created', 'pk']
def __str__(self):
return "Reservation for rack {}".format(self.rack)
def get_absolute_url(self):
return reverse('dcim:rackreservation', args=[self.pk])
def clean(self):
if hasattr(self, 'rack') and self.units:
# Validate that all specified units exist in the Rack.
invalid_units = [u for u in self.units if u not in self.rack.units]
if invalid_units:
raise ValidationError({
'units': "Invalid unit(s) for {}U rack: {}".format(
self.rack.u_height,
', '.join([str(u) for u in invalid_units]),
),
})
# Check that none of the units has already been reserved for this Rack.
reserved_units = []
for resv in self.rack.reservations.exclude(pk=self.pk):
reserved_units += resv.units
conflicting_units = [u for u in self.units if u in reserved_units]
if conflicting_units:
raise ValidationError({
'units': 'The following units have already been reserved: {}'.format(
', '.join([str(u) for u in conflicting_units]),
)
})
def to_csv(self):
return (
self.rack.site.name,
self.rack.group if self.rack.group else None,
self.rack.name,
','.join([str(u) for u in self.units]),
self.tenant.name if self.tenant else None,
self.user.username,
self.description
)
@property
def unit_list(self):
"""
Express the assigned units as a string of summarized ranges. For example:
[0, 1, 2, 10, 14, 15, 16] => "0-2, 10, 14-16"
"""
group = (list(x) for _, x in groupby(sorted(self.units), lambda x, c=count(): next(c) - x))
return ', '.join('-'.join(map(str, (g[0], g[-1])[:len(g)])) for g in group)

246
netbox/dcim/models/sites.py Normal file
View File

@@ -0,0 +1,246 @@
from django.contrib.contenttypes.fields import GenericRelation
from django.db import models
from django.urls import reverse
from mptt.models import MPTTModel, TreeForeignKey
from taggit.managers import TaggableManager
from timezone_field import TimeZoneField
from dcim.choices import *
from dcim.constants import *
from dcim.fields import ASNField
from extras.models import ChangeLoggedModel, CustomFieldModel, ObjectChange, TaggedItem
from extras.utils import extras_features
from utilities.fields import NaturalOrderingField
from utilities.querysets import RestrictedQuerySet
from utilities.mptt import TreeManager
from utilities.utils import serialize_object
__all__ = (
'Region',
'Site',
)
#
# Regions
#
@extras_features('export_templates', 'webhooks')
class Region(MPTTModel, ChangeLoggedModel):
"""
Sites can be grouped within geographic Regions.
"""
parent = TreeForeignKey(
to='self',
on_delete=models.CASCADE,
related_name='children',
blank=True,
null=True,
db_index=True
)
name = models.CharField(
max_length=50,
unique=True
)
slug = models.SlugField(
unique=True
)
description = models.CharField(
max_length=200,
blank=True
)
objects = TreeManager()
csv_headers = ['name', 'slug', 'parent', 'description']
class MPTTMeta:
order_insertion_by = ['name']
def __str__(self):
return self.name
def get_absolute_url(self):
return "{}?region={}".format(reverse('dcim:site_list'), self.slug)
def to_csv(self):
return (
self.name,
self.slug,
self.parent.name if self.parent else None,
self.description,
)
def get_site_count(self):
return Site.objects.filter(
Q(region=self) |
Q(region__in=self.get_descendants())
).count()
def to_objectchange(self, action):
# Remove MPTT-internal fields
return ObjectChange(
changed_object=self,
object_repr=str(self),
action=action,
object_data=serialize_object(self, exclude=['level', 'lft', 'rght', 'tree_id'])
)
#
# Sites
#
@extras_features('custom_fields', 'custom_links', 'graphs', 'export_templates', 'webhooks')
class Site(ChangeLoggedModel, CustomFieldModel):
"""
A Site represents a geographic location within a network; typically a building or campus. The optional facility
field can be used to include an external designation, such as a data center name (e.g. Equinix SV6).
"""
name = models.CharField(
max_length=50,
unique=True
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
slug = models.SlugField(
unique=True
)
status = models.CharField(
max_length=50,
choices=SiteStatusChoices,
default=SiteStatusChoices.STATUS_ACTIVE
)
region = models.ForeignKey(
to='dcim.Region',
on_delete=models.SET_NULL,
related_name='sites',
blank=True,
null=True
)
tenant = models.ForeignKey(
to='tenancy.Tenant',
on_delete=models.PROTECT,
related_name='sites',
blank=True,
null=True
)
facility = models.CharField(
max_length=50,
blank=True,
help_text='Local facility ID or description'
)
asn = ASNField(
blank=True,
null=True,
verbose_name='ASN',
help_text='32-bit autonomous system number'
)
time_zone = TimeZoneField(
blank=True
)
description = models.CharField(
max_length=200,
blank=True
)
physical_address = models.CharField(
max_length=200,
blank=True
)
shipping_address = models.CharField(
max_length=200,
blank=True
)
latitude = models.DecimalField(
max_digits=8,
decimal_places=6,
blank=True,
null=True,
help_text='GPS coordinate (latitude)'
)
longitude = models.DecimalField(
max_digits=9,
decimal_places=6,
blank=True,
null=True,
help_text='GPS coordinate (longitude)'
)
contact_name = models.CharField(
max_length=50,
blank=True
)
contact_phone = models.CharField(
max_length=20,
blank=True
)
contact_email = models.EmailField(
blank=True,
verbose_name='Contact E-mail'
)
comments = models.TextField(
blank=True
)
custom_field_values = GenericRelation(
to='extras.CustomFieldValue',
content_type_field='obj_type',
object_id_field='obj_id'
)
images = GenericRelation(
to='extras.ImageAttachment'
)
tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = [
'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', 'comments',
]
clone_fields = [
'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email',
]
STATUS_CLASS_MAP = {
SiteStatusChoices.STATUS_PLANNED: 'info',
SiteStatusChoices.STATUS_STAGING: 'primary',
SiteStatusChoices.STATUS_ACTIVE: 'success',
SiteStatusChoices.STATUS_DECOMMISSIONING: 'warning',
SiteStatusChoices.STATUS_RETIRED: 'danger',
}
class Meta:
ordering = ('_name',)
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('dcim:site', args=[self.slug])
def to_csv(self):
return (
self.name,
self.slug,
self.get_status_display(),
self.region.name if self.region else None,
self.tenant.name if self.tenant else None,
self.facility,
self.asn,
self.time_zone,
self.description,
self.physical_address,
self.shipping_address,
self.latitude,
self.longitude,
self.contact_name,
self.contact_phone,
self.contact_email,
self.comments,
)
def get_status_class(self):
return self.STATUS_CLASS_MAP.get(self.status)

View File

@@ -141,7 +141,7 @@ POWERPANEL_POWERFEED_COUNT = """
"""
INTERFACE_IPADDRESSES = """
{% for ip in record.ip_addresses.unrestricted %}
{% for ip in record.ip_addresses.all %}
<a href="{{ ip.get_absolute_url }}">{{ ip }}</a><br />
{% endfor %}
"""
@@ -152,6 +152,10 @@ INTERFACE_TAGGED_VLANS = """
{% endfor %}
"""
CONNECTION_STATUS = """
<span class="label label-{% if record.connection_status %}success{% else %}danger{% endif %}">{{ record.get_connection_status_display }}</span>
"""
#
# Regions
@@ -258,12 +262,15 @@ class RackRoleTable(BaseTable):
class RackTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn(
order_by=('_name',)
name = tables.Column(
order_by=('_name',),
linkify=True
)
site = tables.LinkColumn(
viewname='dcim:site',
args=[Accessor('site__slug')]
group = tables.Column(
linkify=True
)
site = tables.Column(
linkify=True
)
tenant = tables.TemplateColumn(
template_code=COL_TENANT
@@ -428,7 +435,8 @@ class ComponentTemplateTable(BaseTable):
class ConsolePortTemplateTable(ComponentTemplateTable):
actions = ButtonsColumn(
model=ConsolePortTemplate,
buttons=('edit', 'delete')
buttons=('edit', 'delete'),
return_url_extra='%23tab_consoleports'
)
class Meta(BaseTable.Meta):
@@ -440,7 +448,8 @@ class ConsolePortTemplateTable(ComponentTemplateTable):
class ConsoleServerPortTemplateTable(ComponentTemplateTable):
actions = ButtonsColumn(
model=ConsoleServerPortTemplate,
buttons=('edit', 'delete')
buttons=('edit', 'delete'),
return_url_extra='%23tab_consoleserverports'
)
class Meta(BaseTable.Meta):
@@ -452,7 +461,8 @@ class ConsoleServerPortTemplateTable(ComponentTemplateTable):
class PowerPortTemplateTable(ComponentTemplateTable):
actions = ButtonsColumn(
model=PowerPortTemplate,
buttons=('edit', 'delete')
buttons=('edit', 'delete'),
return_url_extra='%23tab_powerports'
)
class Meta(BaseTable.Meta):
@@ -464,7 +474,8 @@ class PowerPortTemplateTable(ComponentTemplateTable):
class PowerOutletTemplateTable(ComponentTemplateTable):
actions = ButtonsColumn(
model=PowerOutletTemplate,
buttons=('edit', 'delete')
buttons=('edit', 'delete'),
return_url_extra='%23tab_poweroutlets'
)
class Meta(BaseTable.Meta):
@@ -479,7 +490,8 @@ class InterfaceTemplateTable(ComponentTemplateTable):
)
actions = ButtonsColumn(
model=InterfaceTemplate,
buttons=('edit', 'delete')
buttons=('edit', 'delete'),
return_url_extra='%23tab_interfaces'
)
class Meta(BaseTable.Meta):
@@ -494,7 +506,8 @@ class FrontPortTemplateTable(ComponentTemplateTable):
)
actions = ButtonsColumn(
model=FrontPortTemplate,
buttons=('edit', 'delete')
buttons=('edit', 'delete'),
return_url_extra='%23tab_frontports'
)
class Meta(BaseTable.Meta):
@@ -506,7 +519,8 @@ class FrontPortTemplateTable(ComponentTemplateTable):
class RearPortTemplateTable(ComponentTemplateTable):
actions = ButtonsColumn(
model=RearPortTemplate,
buttons=('edit', 'delete')
buttons=('edit', 'delete'),
return_url_extra='%23tab_rearports'
)
class Meta(BaseTable.Meta):
@@ -518,7 +532,8 @@ class RearPortTemplateTable(ComponentTemplateTable):
class DeviceBayTemplateTable(ComponentTemplateTable):
actions = ButtonsColumn(
model=DeviceBayTemplate,
buttons=('edit', 'delete')
buttons=('edit', 'delete'),
return_url_extra='%23tab_devicebays'
)
class Meta(BaseTable.Meta):
@@ -706,34 +721,48 @@ class DeviceComponentTable(BaseTable):
class ConsolePortTable(DeviceComponentTable):
tags = TagColumn(
url_name='dcim:consoleport_list'
)
class Meta(DeviceComponentTable.Meta):
model = ConsolePort
fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable')
fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable', 'tags')
default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
class ConsoleServerPortTable(DeviceComponentTable):
tags = TagColumn(
url_name='dcim:consoleserverport_list'
)
class Meta(DeviceComponentTable.Meta):
model = ConsoleServerPort
fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable')
fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable', 'tags')
default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
class PowerPortTable(DeviceComponentTable):
tags = TagColumn(
url_name='dcim:powerport_list'
)
class Meta(DeviceComponentTable.Meta):
model = PowerPort
fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'maximum_draw', 'allocated_draw', 'cable')
fields = (
'pk', 'device', 'name', 'label', 'type', 'description', 'maximum_draw', 'allocated_draw', 'cable', 'tags',
)
default_columns = ('pk', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description')
class PowerOutletTable(DeviceComponentTable):
tags = TagColumn(
url_name='dcim:poweroutlet_list'
)
class Meta(DeviceComponentTable.Meta):
model = PowerOutlet
fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'power_port', 'feed_leg', 'cable')
fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'power_port', 'feed_leg', 'cable', 'tags')
default_columns = ('pk', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description')
@@ -753,12 +782,15 @@ class BaseInterfaceTable(BaseTable):
class InterfaceTable(DeviceComponentTable, BaseInterfaceTable):
tags = TagColumn(
url_name='dcim:interface_list'
)
class Meta(DeviceComponentTable.Meta):
model = Interface
fields = (
'pk', 'device', 'name', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address',
'description', 'cable', 'ip_addresses', 'untagged_vlan', 'tagged_vlans',
'description', 'cable', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans',
)
default_columns = ('pk', 'device', 'name', 'label', 'enabled', 'type', 'description')
@@ -767,18 +799,26 @@ class FrontPortTable(DeviceComponentTable):
rear_port_position = tables.Column(
verbose_name='Position'
)
tags = TagColumn(
url_name='dcim:frontport_list'
)
class Meta(DeviceComponentTable.Meta):
model = FrontPort
fields = ('pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable')
fields = (
'pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'tags',
)
default_columns = ('pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description')
class RearPortTable(DeviceComponentTable):
tags = TagColumn(
url_name='dcim:rearport_list'
)
class Meta(DeviceComponentTable.Meta):
model = RearPort
fields = ('pk', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable')
fields = ('pk', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable', 'tags')
default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
@@ -786,10 +826,13 @@ class DeviceBayTable(DeviceComponentTable):
installed_device = tables.Column(
linkify=True
)
tags = TagColumn(
url_name='dcim:devicebay_list'
)
class Meta(DeviceComponentTable.Meta):
model = DeviceBay
fields = ('pk', 'device', 'name', 'label', 'installed_device', 'description')
fields = ('pk', 'device', 'name', 'label', 'installed_device', 'description', 'tags')
default_columns = ('pk', 'device', 'name', 'label', 'installed_device', 'description')
@@ -798,12 +841,16 @@ class InventoryItemTable(DeviceComponentTable):
linkify=True
)
discovered = BooleanColumn()
tags = TagColumn(
url_name='dcim:inventoryitem_list'
)
cable = None # Override DeviceComponentTable
class Meta(DeviceComponentTable.Meta):
model = InventoryItem
fields = (
'pk', 'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description',
'discovered',
'discovered', 'tags',
)
default_columns = ('pk', 'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag')
@@ -876,15 +923,20 @@ class ConsoleConnectionTable(BaseTable):
verbose_name='Console Server'
)
connected_endpoint = tables.Column(
linkify=True,
verbose_name='Port'
)
device = tables.Column(
linkify=True
)
name = tables.Column(
linkify=True,
verbose_name='Console Port'
)
connection_status = BooleanColumn()
connection_status = tables.TemplateColumn(
template_code=CONNECTION_STATUS,
verbose_name='Status'
)
class Meta(BaseTable.Meta):
model = ConsolePort
@@ -901,14 +953,20 @@ class PowerConnectionTable(BaseTable):
)
outlet = tables.Column(
accessor=Accessor('_connected_poweroutlet'),
linkify=True,
verbose_name='Outlet'
)
device = tables.Column(
linkify=True
)
name = tables.Column(
linkify=True,
verbose_name='Power Port'
)
connection_status = tables.TemplateColumn(
template_code=CONNECTION_STATUS,
verbose_name='Status'
)
class Meta(BaseTable.Meta):
model = PowerPort
@@ -940,6 +998,10 @@ class InterfaceConnectionTable(BaseTable):
args=[Accessor('_connected_interface__pk')],
verbose_name='Interface B'
)
connection_status = tables.TemplateColumn(
template_code=CONNECTION_STATUS,
verbose_name='Status'
)
class Meta(BaseTable.Meta):
model = Interface

View File

@@ -100,23 +100,6 @@ class DeviceTestCase(TestCase):
self.assertIn('face', form.errors)
self.assertIn('position', form.errors)
def test_initial_data_population(self):
device_type = DeviceType.objects.first()
cluster = Cluster.objects.first()
test = DeviceForm(initial={
'device_type': device_type.pk,
'device_role': DeviceRole.objects.first().pk,
'status': DeviceStatusChoices.STATUS_ACTIVE,
'site': Site.objects.first().pk,
'cluster': cluster.pk,
})
# Check that the initial value for the manufacturer is set automatically when assigning the device type
self.assertEqual(test.initial['manufacturer'], device_type.manufacturer.pk)
# Check that the initial value for the cluster group is set automatically when assigning the cluster
self.assertEqual(test.initial['cluster_group'], cluster.group.pk)
class LabelTestCase(TestCase):

View File

@@ -1,11 +1,10 @@
from collections import OrderedDict
from django.conf import settings
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 Count, F, Prefetch
from django.db.models import F, Prefetch
from django.forms import ModelMultipleChoiceField, MultipleHiddenInput, modelformset_factory
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
@@ -20,7 +19,7 @@ from ipam.models import IPAddress, Prefix, Service, VLAN
from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
from secrets.models import Secret
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator
from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.permissions import get_permission_for_model
from utilities.utils import csv_format, get_subquery
from utilities.views import (
@@ -169,9 +168,13 @@ class SiteView(ObjectView):
'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(terminations__site=site).count(),
'vm_count': VirtualMachine.objects.restrict(request.user, 'view').filter(cluster__site=site).count(),
}
rack_groups = RackGroup.objects.restrict(request.user, 'view').filter(site=site).annotate(
rack_count=Count('racks')
)
rack_groups = RackGroup.objects.add_related_count(
RackGroup.objects.all(),
Rack,
'group',
'rack_count',
cumulative=True
).restrict(request.user, 'view').filter(site=site)
show_graphs = Graph.objects.filter(type__model='site').exists()
return render(request, 'dcim/site.html', {
@@ -260,7 +263,9 @@ class RackGroupBulkDeleteView(BulkDeleteView):
#
class RackRoleListView(ObjectListView):
queryset = RackRole.objects.annotate(rack_count=Count('racks')).order_by(*RackRole._meta.ordering)
queryset = RackRole.objects.annotate(
rack_count=get_subquery(Rack, 'role')
)
table = tables.RackRoleTable
@@ -280,7 +285,9 @@ class RackRoleBulkImportView(BulkImportView):
class RackRoleBulkDeleteView(BulkDeleteView):
queryset = RackRole.objects.annotate(rack_count=Count('racks')).order_by(*RackRole._meta.ordering)
queryset = RackRole.objects.annotate(
rack_count=get_subquery(Rack, 'role')
)
table = tables.RackRoleTable
@@ -292,8 +299,8 @@ class RackListView(ObjectListView):
queryset = Rack.objects.prefetch_related(
'site', 'group', 'tenant', 'role', 'devices__device_type'
).annotate(
device_count=Count('devices')
).order_by(*Rack._meta.ordering)
device_count=get_subquery(Device, 'rack')
)
filterset = filters.RackFilterSet
filterset_form = forms.RackFilterForm
table = tables.RackDetailTable
@@ -310,8 +317,13 @@ class RackElevationListView(ObjectListView):
racks = filters.RackFilterSet(request.GET, self.queryset).qs
total_count = racks.count()
# Determine ordering
reverse = bool(request.GET.get('reverse', False))
if reverse:
racks = racks.reverse()
# Pagination
per_page = request.GET.get('per_page', settings.PAGINATE_COUNT)
per_page = get_paginate_count(request)
page_number = request.GET.get('page', 1)
paginator = EnhancedPaginator(racks, per_page)
try:
@@ -330,6 +342,7 @@ class RackElevationListView(ObjectListView):
'paginator': paginator,
'page': page,
'total_count': total_count,
'reverse': reverse,
'rack_face': rack_face,
'filter_form': forms.RackElevationFilterForm(request.GET),
})
@@ -408,7 +421,6 @@ class RackReservationListView(ObjectListView):
filterset = filters.RackReservationFilterSet
filterset_form = forms.RackReservationFilterForm
table = tables.RackReservationTable
action_buttons = ('export',)
class RackReservationView(ObjectView):
@@ -499,8 +511,8 @@ class ManufacturerBulkImportView(BulkImportView):
class ManufacturerBulkDeleteView(BulkDeleteView):
queryset = Manufacturer.objects.annotate(
devicetype_count=Count('device_types')
).order_by(*Manufacturer._meta.ordering)
devicetype_count=get_subquery(DeviceType, 'manufacturer')
)
table = tables.ManufacturerTable
@@ -510,8 +522,8 @@ class ManufacturerBulkDeleteView(BulkDeleteView):
class DeviceTypeListView(ObjectListView):
queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
instance_count=Count('instances')
).order_by(*DeviceType._meta.ordering)
instance_count=get_subquery(Device, 'device_type')
)
filterset = filters.DeviceTypeFilterSet
filterset_form = forms.DeviceTypeFilterForm
table = tables.DeviceTypeTable
@@ -620,8 +632,8 @@ class DeviceTypeImportView(ObjectImportView):
class DeviceTypeBulkEditView(BulkEditView):
queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
instance_count=Count('instances')
).order_by(*DeviceType._meta.ordering)
instance_count=get_subquery(Device, 'device_type')
)
filterset = filters.DeviceTypeFilterSet
table = tables.DeviceTypeTable
form = forms.DeviceTypeBulkEditForm
@@ -629,8 +641,8 @@ class DeviceTypeBulkEditView(BulkEditView):
class DeviceTypeBulkDeleteView(BulkDeleteView):
queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
instance_count=Count('instances')
).order_by(*DeviceType._meta.ordering)
instance_count=get_subquery(Device, 'device_type')
)
filterset = filters.DeviceTypeFilterSet
table = tables.DeviceTypeTable
@@ -1033,7 +1045,7 @@ class DeviceView(ObjectView):
)
# Interfaces
interfaces = device.vc_interfaces.restrict(request.user, 'view').filter(device=device).prefetch_related(
interfaces = device.vc_interfaces.restrict(request.user, 'view').prefetch_related(
Prefetch('ip_addresses', queryset=IPAddress.objects.restrict(request.user)),
Prefetch('member_interfaces', queryset=Interface.objects.restrict(request.user)),
'lag', '_connected_interface__device', '_connected_circuittermination__circuit', 'cable',
@@ -1155,7 +1167,7 @@ class DeviceConfigView(ObjectView):
class DeviceConfigContextView(ObjectConfigContextView):
queryset = Device.objects.all()
queryset = Device.objects.annotate_config_context_data()
base_template = 'dcim/device.html'
@@ -1233,6 +1245,7 @@ class ConsolePortCreateView(ComponentCreateView):
class ConsolePortEditView(ObjectEditView):
queryset = ConsolePort.objects.all()
model_form = forms.ConsolePortForm
template_name = 'dcim/device_component_edit.html'
class ConsolePortDeleteView(ObjectDeleteView):
@@ -1292,6 +1305,7 @@ class ConsoleServerPortCreateView(ComponentCreateView):
class ConsoleServerPortEditView(ObjectEditView):
queryset = ConsoleServerPort.objects.all()
model_form = forms.ConsoleServerPortForm
template_name = 'dcim/device_component_edit.html'
class ConsoleServerPortDeleteView(ObjectDeleteView):
@@ -1351,6 +1365,7 @@ class PowerPortCreateView(ComponentCreateView):
class PowerPortEditView(ObjectEditView):
queryset = PowerPort.objects.all()
model_form = forms.PowerPortForm
template_name = 'dcim/device_component_edit.html'
class PowerPortDeleteView(ObjectDeleteView):
@@ -1410,6 +1425,7 @@ class PowerOutletCreateView(ComponentCreateView):
class PowerOutletEditView(ObjectEditView):
queryset = PowerOutlet.objects.all()
model_form = forms.PowerOutletForm
template_name = 'dcim/device_component_edit.html'
class PowerOutletDeleteView(ObjectDeleteView):
@@ -1561,6 +1577,7 @@ class FrontPortCreateView(ComponentCreateView):
class FrontPortEditView(ObjectEditView):
queryset = FrontPort.objects.all()
model_form = forms.FrontPortForm
template_name = 'dcim/device_component_edit.html'
class FrontPortDeleteView(ObjectDeleteView):
@@ -1620,6 +1637,7 @@ class RearPortCreateView(ComponentCreateView):
class RearPortEditView(ObjectEditView):
queryset = RearPort.objects.all()
model_form = forms.RearPortForm
template_name = 'dcim/device_component_edit.html'
class RearPortDeleteView(ObjectDeleteView):
@@ -1679,6 +1697,7 @@ class DeviceBayCreateView(ComponentCreateView):
class DeviceBayEditView(ObjectEditView):
queryset = DeviceBay.objects.all()
model_form = forms.DeviceBayForm
template_name = 'dcim/device_component_edit.html'
class DeviceBayDeleteView(ObjectDeleteView):
@@ -2028,8 +2047,11 @@ class CableCreateView(ObjectEditView):
initial_data = {k: request.GET[k] for k in request.GET}
# Set initial site and rack based on side A termination (if not already set)
termination_a_site = getattr(obj.termination_a.parent, 'site', None)
if termination_a_site and 'termination_b_region' not in initial_data:
initial_data['termination_b_region'] = termination_a_site.region
if 'termination_b_site' not in initial_data:
initial_data['termination_b_site'] = getattr(obj.termination_a.parent, 'site', None)
initial_data['termination_b_site'] = termination_a_site
if 'termination_b_rack' not in initial_data:
initial_data['termination_b_rack'] = getattr(obj.termination_a.parent, 'rack', None)
@@ -2180,8 +2202,8 @@ class InterfaceConnectionsListView(ObjectListView):
class VirtualChassisListView(ObjectListView):
queryset = VirtualChassis.objects.prefetch_related('master').annotate(
member_count=Count('members', distinct=True)
).order_by(*VirtualChassis._meta.ordering)
member_count=get_subquery(Device, 'virtual_chassis')
)
table = tables.VirtualChassisTable
filterset = filters.VirtualChassisFilterSet
filterset_form = forms.VirtualChassisFilterForm
@@ -2412,8 +2434,8 @@ class PowerPanelListView(ObjectListView):
queryset = PowerPanel.objects.prefetch_related(
'site', 'rack_group'
).annotate(
powerfeed_count=Count('powerfeeds')
).order_by(*PowerPanel._meta.ordering)
powerfeed_count=get_subquery(PowerFeed, 'power_panel')
)
filterset = filters.PowerPanelFilterSet
filterset_form = forms.PowerPanelFilterForm
table = tables.PowerPanelTable
@@ -2464,8 +2486,8 @@ class PowerPanelBulkDeleteView(BulkDeleteView):
queryset = PowerPanel.objects.prefetch_related(
'site', 'rack_group'
).annotate(
rack_count=Count('powerfeeds')
).order_by(*PowerPanel._meta.ordering)
powerfeed_count=get_subquery(PowerFeed, 'power_panel')
)
filterset = filters.PowerPanelFilterSet
table = tables.PowerPanelTable

View File

@@ -158,7 +158,7 @@ class CustomFieldModelSerializer(ValidatedModelSerializer):
instance.custom_fields = {}
for field in custom_fields:
value = instance.cf.get(field.name)
if field.type == CustomFieldTypeChoices.TYPE_SELECT and value is not None:
if field.type == CustomFieldTypeChoices.TYPE_SELECT and type(value) is CustomFieldChoice:
instance.custom_fields[field.name] = CustomFieldChoiceSerializer(value).data
else:
instance.custom_fields[field.name] = value
@@ -176,13 +176,12 @@ class CustomFieldModelSerializer(ValidatedModelSerializer):
def create(self, validated_data):
custom_fields = validated_data.pop('custom_fields', None)
with transaction.atomic():
instance = super().create(validated_data)
# Save custom fields
custom_fields = validated_data.get('custom_fields')
if custom_fields is not None:
self._save_custom_fields(instance, custom_fields)
instance.custom_fields = custom_fields
@@ -191,10 +190,11 @@ class CustomFieldModelSerializer(ValidatedModelSerializer):
def update(self, instance, validated_data):
custom_fields = validated_data.pop('custom_fields', None)
with transaction.atomic():
custom_fields = validated_data.get('custom_fields')
instance._cf = custom_fields
instance = super().update(instance, validated_data)
# Save custom fields

View File

@@ -101,20 +101,30 @@ class TaggedObjectSerializer(serializers.Serializer):
tags = NestedTagSerializer(many=True, required=False)
def create(self, validated_data):
tags = validated_data.pop('tags', [])
tags = validated_data.pop('tags', None)
instance = super().create(validated_data)
return self._save_tags(instance, tags)
if tags is not None:
return self._save_tags(instance, tags)
return instance
def update(self, instance, validated_data):
tags = validated_data.pop('tags', [])
tags = validated_data.pop('tags', None)
# Cache tags on instance for change logging
instance._tags = tags or []
instance = super().update(instance, validated_data)
return self._save_tags(instance, tags)
if tags is not None:
return self._save_tags(instance, tags)
return instance
def _save_tags(self, instance, tags):
if tags:
instance.tags.set(*[t.name for t in tags])
else:
instance.tags.clear()
return instance

View File

@@ -1,7 +1,6 @@
from collections import OrderedDict
from django.contrib.contenttypes.models import ContentType
from django.db.models import Count
from django.http import Http404
from django_rq.queues import get_connection
from rest_framework import status
@@ -15,17 +14,40 @@ from rq import Worker
from extras import filters
from extras.choices import JobResultStatusChoices
from extras.models import (
ConfigContext, CustomFieldChoice, ExportTemplate, Graph, ImageAttachment, ObjectChange, JobResult, Tag,
ConfigContext, CustomFieldChoice, ExportTemplate, Graph, ImageAttachment, ObjectChange, JobResult, Tag, TaggedItem,
)
from extras.reports import get_report, get_reports, run_report
from extras.scripts import get_script, get_scripts, run_script
from utilities.api import IsAuthenticatedOrLoginNotRequired, ModelViewSet
from utilities.exceptions import RQWorkerNotRunningException
from utilities.metadata import ContentTypeMetadata
from utilities.utils import copy_safe_request
from utilities.utils import copy_safe_request, get_subquery
from . import serializers
class ConfigContextQuerySetMixin:
"""
Used by views that work with config context models (device and virtual machine).
Provides a get_queryset() method which deals with adding the config context
data annotation or not.
"""
def get_queryset(self):
"""
Build the proper queryset based on the request context
If the `brief` query param equates to True or the `exclude` query param
includes `config_context` as a value, return the base queryset.
Else, return the queryset annotated with config context data
"""
request = self.get_serializer_context()['request']
if request.query_params.get('brief') or 'config_context' in request.query_params.get('exclude', []):
return self.queryset
return self.queryset.annotate_config_context_data()
class ExtrasRootView(APIRootView):
"""
Extras API root view
@@ -126,8 +148,8 @@ class ExportTemplateViewSet(ModelViewSet):
class TagViewSet(ModelViewSet):
queryset = Tag.objects.annotate(
tagged_items=Count('extras_taggeditem_items')
).order_by(*Tag._meta.ordering)
tagged_items=get_subquery(TaggedItem, 'tag')
)
serializer_class = serializers.TagSerializer
filterset_class = filters.TagFilterSet
@@ -140,6 +162,7 @@ class ImageAttachmentViewSet(ModelViewSet):
metadata_class = ContentTypeMetadata
queryset = ImageAttachment.objects.all()
serializer_class = serializers.ImageAttachmentSerializer
filterset_class = filters.ImageAttachmentFilterSet
#

View File

@@ -0,0 +1,32 @@
from contextlib import contextmanager
from django.db.models.signals import m2m_changed, pre_delete, post_save
from extras.signals import _handle_changed_object, _handle_deleted_object
from utilities.utils import curry
@contextmanager
def change_logging(request):
"""
Enable change logging by connecting the appropriate signals to their receivers before code is run, and
disconnecting them afterward.
:param request: WSGIRequest object with a unique `id` set
"""
# Curry signals receivers to pass the current request
handle_changed_object = curry(_handle_changed_object, request)
handle_deleted_object = curry(_handle_deleted_object, request)
# Connect our receivers to the post_save and post_delete signals.
post_save.connect(handle_changed_object, dispatch_uid='handle_changed_object')
m2m_changed.connect(handle_changed_object, dispatch_uid='handle_changed_object')
pre_delete.connect(handle_deleted_object, dispatch_uid='handle_deleted_object')
yield
# Disconnect change logging signals. This is necessary to avoid recording any errant
# changes during test cleanup.
post_save.disconnect(handle_changed_object, dispatch_uid='handle_changed_object')
m2m_changed.disconnect(handle_changed_object, dispatch_uid='handle_changed_object')
pre_delete.disconnect(handle_deleted_object, dispatch_uid='handle_deleted_object')

View File

@@ -1,4 +1,5 @@
import django_filters
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
@@ -7,7 +8,7 @@ from tenancy.models import Tenant, TenantGroup
from utilities.filters import BaseFilterSet
from virtualization.models import Cluster, ClusterGroup
from .choices import *
from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, JobResult, Tag
from .models import ConfigContext, CustomField, ExportTemplate, Graph, ImageAttachment, JobResult, ObjectChange, Tag
__all__ = (
@@ -17,6 +18,7 @@ __all__ = (
'CustomFieldFilterSet',
'ExportTemplateFilterSet',
'GraphFilterSet',
'ImageAttachmentFilterSet',
'LocalConfigContextFilterSet',
'ObjectChangeFilterSet',
'TagFilterSet',
@@ -104,6 +106,13 @@ class ExportTemplateFilterSet(BaseFilterSet):
fields = ['id', 'content_type', 'name', 'template_language']
class ImageAttachmentFilterSet(BaseFilterSet):
class Meta:
model = ImageAttachment
fields = ['id', 'content_type', 'object_id', 'name']
class TagFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
@@ -251,12 +260,21 @@ class ObjectChangeFilterSet(BaseFilterSet):
label='Search',
)
time = django_filters.DateTimeFromToRangeFilter()
user_id = django_filters.ModelMultipleChoiceFilter(
queryset=User.objects.all(),
label='User (ID)',
)
user = django_filters.ModelMultipleChoiceFilter(
field_name='user__username',
queryset=User.objects.all(),
to_field_name='username',
label='User name',
)
class Meta:
model = ObjectChange
fields = [
'id', 'user', 'user_name', 'request_id', 'action', 'changed_object_type', 'changed_object_id',
'object_repr',
'id', 'user_name', 'request_id', 'action', 'changed_object_type', 'changed_object_id', 'object_repr',
]
def search(self, queryset, name, value):

View File

@@ -29,6 +29,9 @@ class CustomFieldModelForm(forms.ModelForm):
super().__init__(*args, **kwargs)
if self.instance._cf is None:
self.instance._cf = {}
self._append_customfield_fields()
def _append_customfield_fields(self):
@@ -48,9 +51,12 @@ class CustomFieldModelForm(forms.ModelForm):
field_name = 'cf_{}'.format(cf.name)
if self.instance.pk:
self.fields[field_name] = cf.to_form_field(set_initial=False)
self.fields[field_name].initial = self.custom_field_values.get(cf.name)
value = self.custom_field_values.get(cf.name)
self.fields[field_name].initial = value
self.instance._cf[cf.name] = value
else:
self.fields[field_name] = cf.to_form_field()
self.instance._cf[cf.name] = self.fields[field_name].initial
# Annotate the field in the list of CustomField form fields
self.custom_fields.append(field_name)
@@ -77,13 +83,18 @@ class CustomFieldModelForm(forms.ModelForm):
cfv.save()
def save(self, commit=True):
# Cache custom field values on object prior to save to ensure change logging
for cf_name in self.custom_fields:
self.instance._cf[cf_name[3:]] = self.cleaned_data.get(cf_name)
obj = super().save(commit)
# Handle custom fields the same way we do M2M fields
if commit:
self._save_custom_fields()
else:
self.save_custom_fields = self._save_custom_fields
obj.save_custom_fields = self._save_custom_fields
return obj
@@ -386,10 +397,11 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
required=False,
widget=StaticSelect2()
)
user = DynamicModelMultipleChoiceField(
user_id = DynamicModelMultipleChoiceField(
queryset=User.objects.all(),
required=False,
display_field='username',
label='User',
widget=APISelectMultiple(
api_url='/api/users/users/',
)

View File

@@ -25,12 +25,18 @@ class Command(BaseCommand):
help = "Start the Django shell with all NetBox models already imported"
django_models = {}
def add_arguments(self, parser):
parser.add_argument(
'-c', '--command',
help='Python code to execute (instead of starting an interactive shell)',
)
def _lsmodels(self):
for app, models in self.django_models.items():
app_name = apps.get_app_config(app).verbose_name
print('{}:'.format(app_name))
print(f'{app_name}:')
for m in models:
print(' {}'.format(m))
print(f' {m}')
def get_namespace(self):
namespace = {}
@@ -46,7 +52,7 @@ class Command(BaseCommand):
# Constants
try:
app_constants = sys.modules['{}.constants'.format(app)]
app_constants = sys.modules[f'{app}.constants']
for name in dir(app_constants):
namespace[name] = getattr(app_constants, name)
except KeyError:
@@ -64,5 +70,10 @@ class Command(BaseCommand):
return namespace
def handle(self, **options):
# If Python code has been passed, execute it and exit.
if options['command']:
exec(options['command'], self.get_namespace())
return
shell = code.interact(banner=BANNER_TEXT, local=self.get_namespace())
return shell

View File

@@ -1,7 +1,12 @@
import time
from django.contrib.contenttypes.models import ContentType
from django.core.management.base import BaseCommand
from django.utils import timezone
from extras.reports import get_reports
from extras.choices import JobResultStatusChoices
from extras.models import JobResult
from extras.reports import get_reports, run_report
class Command(BaseCommand):
@@ -20,15 +25,33 @@ class Command(BaseCommand):
for report in report_list:
if module_name in options['reports'] or report.full_name in options['reports']:
# Run the report and create a new ReportResult
# Run the report and create a new JobResult
self.stdout.write(
"[{:%H:%M:%S}] Running {}...".format(timezone.now(), report.full_name)
)
report.run()
report_content_type = ContentType.objects.get(app_label='extras', model='report')
job_result = JobResult.enqueue_job(
run_report,
report.full_name,
report_content_type,
None
)
# Wait on the job to finish
while job_result.status not in JobResultStatusChoices.TERMINAL_STATE_CHOICES:
time.sleep(1)
job_result = JobResult.objects.get(pk=job_result.pk)
# Report on success/failure
status = self.style.ERROR('FAILED') if report.failed else self.style.SUCCESS('SUCCESS')
for test_name, attrs in report.result.data.items():
if job_result.status == JobResultStatusChoices.STATUS_FAILED:
status = self.style.ERROR('FAILED')
elif job_result == JobResultStatusChoices.STATUS_ERRORED:
status = self.style.ERROR('ERRORED')
else:
status = self.style.SUCCESS('SUCCESS')
for test_name, attrs in job_result.data.items():
self.stdout.write(
"\t{}: {} success, {} info, {} warning, {} failure".format(
test_name, attrs['success'], attrs['info'], attrs['warning'], attrs['failure']
@@ -37,6 +60,9 @@ class Command(BaseCommand):
self.stdout.write(
"[{:%H:%M:%S}] {}: {}".format(timezone.now(), report.full_name, status)
)
self.stdout.write(
"[{:%H:%M:%S}] {}: Duration {}".format(timezone.now(), report.full_name, job_result.duration)
)
# Wrap things up
self.stdout.write(

View File

@@ -1,64 +1,6 @@
import random
import threading
import uuid
from copy import deepcopy
from datetime import timedelta
from django.conf import settings
from django.contrib import messages
from django.db.models.signals import pre_delete, post_save
from django.utils import timezone
from django_prometheus.models import model_deletes, model_inserts, model_updates
from redis.exceptions import RedisError
from extras.utils import is_taggable
from utilities.api import is_api_request
from utilities.querysets import DummyQuerySet
from .choices import ObjectChangeActionChoices
from .models import ObjectChange
from .signals import purge_changelog
from .webhooks import enqueue_webhooks
_thread_locals = threading.local()
def handle_changed_object(sender, instance, **kwargs):
"""
Fires when an object is created or updated.
"""
# Queue the object for processing once the request completes
action = ObjectChangeActionChoices.ACTION_CREATE if kwargs['created'] else ObjectChangeActionChoices.ACTION_UPDATE
_thread_locals.changed_objects.append(
(instance, action)
)
def handle_deleted_object(sender, instance, **kwargs):
"""
Fires when an object is deleted.
"""
# Cache custom fields prior to copying the instance
if hasattr(instance, 'cache_custom_fields'):
instance.cache_custom_fields()
# Create a copy of the object being deleted
copy = deepcopy(instance)
# Preserve tags
if is_taggable(instance):
copy.tags = DummyQuerySet(instance.tags.all())
# Queue the copy of the object for processing once the request completes
_thread_locals.changed_objects.append(
(copy, ObjectChangeActionChoices.ACTION_DELETE)
)
def purge_objectchange_cache(sender, **kwargs):
"""
Delete any queued object changes waiting to be written.
"""
_thread_locals.changed_objects = []
from .context_managers import change_logging
class ObjectChangeMiddleware(object):
@@ -79,74 +21,12 @@ class ObjectChangeMiddleware(object):
self.get_response = get_response
def __call__(self, request):
# Initialize an empty list to cache objects being saved.
_thread_locals.changed_objects = []
# Assign a random unique ID to the request. This will be used to associate multiple object changes made during
# the same request.
request.id = uuid.uuid4()
# Connect our receivers to the post_save and post_delete signals.
post_save.connect(handle_changed_object, dispatch_uid='handle_changed_object')
pre_delete.connect(handle_deleted_object, dispatch_uid='handle_deleted_object')
# Provide a hook for purging the change cache
purge_changelog.connect(purge_objectchange_cache)
# Process the request
response = self.get_response(request)
# If the change cache is empty, there's nothing more we need to do.
if not _thread_locals.changed_objects:
return response
# Disconnect our receivers from the post_save and post_delete signals.
post_save.disconnect(handle_changed_object, dispatch_uid='handle_changed_object')
pre_delete.disconnect(handle_deleted_object, dispatch_uid='handle_deleted_object')
# Create records for any cached objects that were changed.
redis_failed = False
for instance, action in _thread_locals.changed_objects:
# Refresh cached custom field values
if action in [ObjectChangeActionChoices.ACTION_CREATE, ObjectChangeActionChoices.ACTION_UPDATE]:
if hasattr(instance, 'cache_custom_fields'):
instance.cache_custom_fields()
# Record an ObjectChange if applicable
if hasattr(instance, 'to_objectchange'):
objectchange = instance.to_objectchange(action)
objectchange.user = request.user
objectchange.request_id = request.id
objectchange.save()
# Enqueue webhooks
try:
enqueue_webhooks(instance, request.user, request.id, action)
except RedisError as e:
if not redis_failed and not is_api_request(request):
messages.error(
request,
"There was an error processing webhooks for this request. Check that the Redis service is "
"running and reachable. The full error details were: {}".format(e)
)
redis_failed = True
# Increment metric counters
if action == ObjectChangeActionChoices.ACTION_CREATE:
model_inserts.labels(instance._meta.model_name).inc()
elif action == ObjectChangeActionChoices.ACTION_UPDATE:
model_updates.labels(instance._meta.model_name).inc()
elif action == ObjectChangeActionChoices.ACTION_DELETE:
model_deletes.labels(instance._meta.model_name).inc()
# Housekeeping: 1% chance of clearing out expired ObjectChanges. This applies only to requests which result in
# one or more changes being logged.
if settings.CHANGELOG_RETENTION and random.randint(1, 100) == 1:
cutoff = timezone.now() - timedelta(days=settings.CHANGELOG_RETENTION)
purged_count, _ = ObjectChange.objects.filter(
time__lt=cutoff
).delete()
# Process the request with change logging enabled
with change_logging(request):
response = self.get_response(request)
return response

View File

@@ -1,4 +1,3 @@
import logging
from collections import OrderedDict
from datetime import date
@@ -18,11 +17,14 @@ from extras.utils import FeatureQuery
#
class CustomFieldModel(models.Model):
_cf = None
class Meta:
abstract = True
def __init__(self, *args, custom_fields=None, **kwargs):
self._cf = custom_fields
super().__init__(*args, **kwargs)
def cache_custom_fields(self):
"""
Cache all custom field values for this instance
@@ -198,15 +200,14 @@ class CustomField(models.Model):
# Select
elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
choices = [(cfc.pk, cfc.value) for cfc in self.choices.all()]
default_choice = self.choices.filter(value=self.default).first()
if not required:
if not required or default_choice is None:
choices = add_blank_choice(choices)
# Set the initial value to the PK of the default choice, if any
if set_initial:
default_choice = self.choices.filter(value=self.default).first()
if default_choice:
initial = default_choice.pk
if set_initial and default_choice:
initial = default_choice.pk
field_class = CSVChoiceField if for_csv_import else forms.ChoiceField
field = field_class(

View File

@@ -542,8 +542,16 @@ class ConfigContextModel(models.Model):
# Compile all config data, overwriting lower-weight values with higher-weight values where a collision occurs
data = OrderedDict()
for context in ConfigContext.objects.get_for_object(self):
data = deepmerge(data, context.data)
if not hasattr(self, 'config_context_data'):
# The annotation is not available, so we fall back to manually querying for the config context objects
config_context_data = ConfigContext.objects.get_for_object(self, aggregate_data=True)
else:
# The attribute may exist, but the annotated value could be None if there is no config context data
config_context_data = self.config_context_data or []
for context in config_context_data:
data = deepmerge(data, context)
# If the object has local config context data defined, merge it last
if self.local_context_data:
@@ -652,15 +660,13 @@ class JobResult(models.Model):
def set_status(self, status):
"""
Helper method to change the status of the job result and save. If the target status is terminal, the
completion time is also set.
Helper method to change the status of the job result. If the target status is terminal, the completion
time is also set.
"""
self.status = status
if status in JobResultStatusChoices.TERMINAL_STATE_CHOICES:
self.completed = timezone.now()
self.save()
@classmethod
def enqueue_job(cls, func, name, obj_type, user, *args, **kwargs):
"""

View File

@@ -3,7 +3,6 @@ import inspect
from packaging import version
from django.apps import AppConfig
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.template.loader import get_template
@@ -71,10 +70,10 @@ class PluginConfig(AppConfig):
register_menu_items(self.verbose_name, menu_items)
@classmethod
def validate(cls, user_config):
def validate(cls, user_config, netbox_version):
# Enforce version constraints
current_version = version.parse(settings.VERSION)
current_version = version.parse(netbox_version)
if cls.min_version is not None:
min_version = version.parse(cls.min_version)
if current_version < min_version:

View File

@@ -10,8 +10,6 @@ from rest_framework.response import Response
from rest_framework.reverse import reverse
from rest_framework.views import APIView
from extras.plugins.utils import import_object
class InstalledPluginsAdminView(View):
"""
@@ -62,11 +60,7 @@ class PluginsAPIRootView(APIView):
@staticmethod
def _get_plugin_entry(plugin, app_config, request, format):
# Check if the plugin specifies any API URLs
api_app_name = import_object(f"{plugin}.api.urls.app_name")
if api_app_name is None:
# Plugin does not expose an API
return None
api_app_name = f'{app_config.name}-api'
try:
entry = (getattr(app_config, 'base_url', app_config.label), reverse(
f"plugins-api:{api_app_name}:api-root",

View File

@@ -1,7 +1,9 @@
from collections import OrderedDict
from django.db.models import Q, QuerySet
from django.db.models import OuterRef, Subquery, Q
from extras.models.tags import TaggedItem
from utilities.query_functions import EmptyGroupByJSONBAgg, OrderableJSONBAgg
from utilities.querysets import RestrictedQuerySet
@@ -23,9 +25,12 @@ class CustomFieldQueryset:
class ConfigContextQuerySet(RestrictedQuerySet):
def get_for_object(self, obj):
def get_for_object(self, obj, aggregate_data=False):
"""
Return all applicable ConfigContexts for a given object. Only active ConfigContexts will be included.
Args:
aggregate_data: If True, use the JSONBAgg aggregate function to return only the list of JSON data objects
"""
# `device_role` for Device; `role` for VirtualMachine
@@ -45,7 +50,7 @@ class ConfigContextQuerySet(RestrictedQuerySet):
else:
regions = []
return self.filter(
queryset = self.filter(
Q(regions__in=regions) | Q(regions=None),
Q(sites=obj.site) | Q(sites=None),
Q(roles=role) | Q(roles=None),
@@ -56,4 +61,87 @@ class ConfigContextQuerySet(RestrictedQuerySet):
Q(tenants=obj.tenant) | Q(tenants=None),
Q(tags__slug__in=obj.tags.slugs()) | Q(tags=None),
is_active=True,
).order_by('weight', 'name')
).order_by('weight', 'name').distinct()
if aggregate_data:
return queryset.aggregate(
config_context_data=OrderableJSONBAgg('data', ordering=['weight', 'name'])
)['config_context_data']
return queryset
class ConfigContextModelQuerySet(RestrictedQuerySet):
"""
QuerySet manager used by models which support ConfigContext (device and virtual machine).
Includes a method which appends an annotation of aggregated config context JSON data objects. This is
implemented as a subquery which performs all the joins necessary to filter relevant config context objects.
This offers a substantial performance gain over ConfigContextQuerySet.get_for_object() when dealing with
multiple objects.
This allows the annotation to be entirely optional.
"""
def annotate_config_context_data(self):
"""
Attach the subquery annotation to the base queryset
"""
from extras.models import ConfigContext
return self.annotate(
config_context_data=Subquery(
ConfigContext.objects.filter(
self._get_config_context_filters()
).annotate(
_data=EmptyGroupByJSONBAgg('data', ordering=['weight', 'name'])
).values("_data")
)
).distinct()
def _get_config_context_filters(self):
# Construct the set of Q objects for the specific object types
tag_query_filters = {
"object_id": OuterRef(OuterRef('pk')),
"content_type__app_label": self.model._meta.app_label,
"content_type__model": self.model._meta.model_name
}
base_query = Q(
Q(platforms=OuterRef('platform')) | Q(platforms=None),
Q(tenant_groups=OuterRef('tenant__group')) | Q(tenant_groups=None),
Q(tenants=OuterRef('tenant')) | Q(tenants=None),
Q(
tags__pk__in=Subquery(
TaggedItem.objects.filter(
**tag_query_filters
).values_list(
'tag_id',
flat=True
)
)
) | Q(tags=None),
is_active=True,
)
if self.model._meta.model_name == 'device':
base_query.add((Q(roles=OuterRef('device_role')) | Q(roles=None)), Q.AND)
base_query.add((Q(sites=OuterRef('site')) | Q(sites=None)), Q.AND)
region_field = 'site__region'
elif self.model._meta.model_name == 'virtualmachine':
base_query.add((Q(roles=OuterRef('role')) | Q(roles=None)), Q.AND)
base_query.add((Q(cluster_groups=OuterRef('cluster__group')) | Q(cluster_groups=None)), Q.AND)
base_query.add((Q(clusters=OuterRef('cluster')) | Q(clusters=None)), Q.AND)
base_query.add((Q(sites=OuterRef('cluster__site')) | Q(sites=None)), Q.AND)
region_field = 'cluster__site__region'
base_query.add(
(Q(
regions__tree_id=OuterRef(f'{region_field}__tree_id'),
regions__level__lte=OuterRef(f'{region_field}__level'),
regions__lft__lte=OuterRef(f'{region_field}__lft'),
regions__rght__gte=OuterRef(f'{region_field}__rght'),
) | Q(regions=None)),
Q.AND
)
return base_query

View File

@@ -2,10 +2,10 @@ import importlib
import inspect
import logging
import pkgutil
import traceback
from collections import OrderedDict
from django.conf import settings
from django.db.models import Q
from django.utils import timezone
from django_rq import job
@@ -79,6 +79,7 @@ def run_report(job_result, *args, **kwargs):
except Exception as e:
print(e)
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
job_result.save()
logging.error(f"Error during execution of report {job_result.name}")
# Delete any previous terminal state results
@@ -170,7 +171,7 @@ class Report(object):
timezone.now().isoformat(),
level,
str(obj) if obj else None,
obj.get_absolute_url() if getattr(obj, 'get_absolute_url', None) else None,
obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None,
message,
))
@@ -223,17 +224,25 @@ class Report(object):
job_result.status = JobResultStatusChoices.STATUS_RUNNING
job_result.save()
for method_name in self.test_methods:
self.active_test = method_name
test_method = getattr(self, method_name)
test_method()
try:
if self.failed:
self.logger.warning("Report failed")
job_result.status = JobResultStatusChoices.STATUS_FAILED
else:
self.logger.info("Report completed successfully")
job_result.status = JobResultStatusChoices.STATUS_COMPLETED
for method_name in self.test_methods:
self.active_test = method_name
test_method = getattr(self, method_name)
test_method()
if self.failed:
self.logger.warning("Report failed")
job_result.status = JobResultStatusChoices.STATUS_FAILED
else:
self.logger.info("Report completed successfully")
job_result.status = JobResultStatusChoices.STATUS_COMPLETED
except Exception as e:
stacktrace = traceback.format_exc()
self.log_failure(None, f"An exception occurred: {type(e).__name__}: {e} <pre>{stacktrace}</pre>")
logger.error(f"Exception raised during report execution: {e}")
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
job_result.data = self._results
job_result.completed = timezone.now()

View File

@@ -22,8 +22,8 @@ from ipam.formfields import IPAddressFormField, IPNetworkFormField
from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
from utilities.exceptions import AbortTransaction
from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField
from .context_managers import change_logging
from .forms import ScriptForm
from .signals import purge_changelog
__all__ = [
'BaseScript',
@@ -436,41 +436,49 @@ def run_script(data, request, commit=True, *args, **kwargs):
if 'commit' in inspect.signature(script.run).parameters:
kwargs['commit'] = commit
else:
warnings.warn(f"The run() method of script {script} should support a 'commit' argument. This will be required "
f"beginning with NetBox v2.10.")
try:
with transaction.atomic():
script.output = script.run(**kwargs)
if not commit:
raise AbortTransaction()
except AbortTransaction:
pass
except Exception as e:
stacktrace = traceback.format_exc()
script.log_failure(
"An exception occurred: `{}: {}`\n```\n{}\n```".format(type(e).__name__, e, stacktrace)
warnings.warn(
f"The run() method of script {script} should support a 'commit' argument. This will be required beginning "
f"with NetBox v2.10."
)
logger.error(f"Exception raised during script execution: {e}")
commit = False
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
finally:
if job_result.status != JobResultStatusChoices.STATUS_ERRORED:
job_result.data = ScriptOutputSerializer(script).data
job_result.set_status(JobResultStatusChoices.STATUS_COMPLETED)
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(**kwargs)
job_result.set_status(JobResultStatusChoices.STATUS_COMPLETED)
if not commit:
# Delete all pending changelog entries
purge_changelog.send(Script)
script.log_info(
"Database changes have been reverted automatically."
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)
logger.info(f"Script completed in {job_result.duration}")
finally:
job_result.data = ScriptOutputSerializer(script).data
job_result.save()
logger.info(f"Script completed in {job_result.duration}")
# Execute the script. If commit is True, wrap it with the change_logging context manager to ensure we process
# change logging, webhooks, etc.
if commit:
with change_logging(request):
_run_script()
else:
_run_script()
# Delete any previous terminal state results
JobResult.objects.filter(

View File

@@ -1,7 +1,75 @@
import random
from datetime import timedelta
from cacheops.signals import cache_invalidated, cache_read
from django.dispatch import Signal
from django.conf import settings
from django.utils import timezone
from django_prometheus.models import model_deletes, model_inserts, model_updates
from prometheus_client import Counter
from .choices import ObjectChangeActionChoices
from .models import ObjectChange
from .webhooks import enqueue_webhooks
#
# Change logging/webhooks
#
def _handle_changed_object(request, sender, instance, **kwargs):
"""
Fires when an object is created or updated.
"""
# Queue the object for processing once the request completes
if kwargs.get('created'):
action = ObjectChangeActionChoices.ACTION_CREATE
elif 'created' in kwargs:
action = ObjectChangeActionChoices.ACTION_UPDATE
elif kwargs.get('action') in ['post_add', 'post_remove'] and kwargs['pk_set']:
# m2m_changed with objects added or removed
action = ObjectChangeActionChoices.ACTION_UPDATE
else:
return
# Record an ObjectChange if applicable
if hasattr(instance, 'to_objectchange'):
objectchange = instance.to_objectchange(action)
objectchange.user = request.user
objectchange.request_id = request.id
objectchange.save()
# Enqueue webhooks
enqueue_webhooks(instance, request.user, request.id, action)
# Increment metric counters
if action == ObjectChangeActionChoices.ACTION_CREATE:
model_inserts.labels(instance._meta.model_name).inc()
elif action == ObjectChangeActionChoices.ACTION_UPDATE:
model_updates.labels(instance._meta.model_name).inc()
# Housekeeping: 0.1% chance of clearing out expired ObjectChanges
if settings.CHANGELOG_RETENTION and random.randint(1, 1000) == 1:
cutoff = timezone.now() - timedelta(days=settings.CHANGELOG_RETENTION)
ObjectChange.objects.filter(time__lt=cutoff).delete()
def _handle_deleted_object(request, sender, instance, **kwargs):
"""
Fires when an object is deleted.
"""
# Record an ObjectChange if applicable
if hasattr(instance, 'to_objectchange'):
objectchange = instance.to_objectchange(ObjectChangeActionChoices.ACTION_DELETE)
objectchange.user = request.user
objectchange.request_id = request.id
objectchange.save()
# Enqueue webhooks
enqueue_webhooks(instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE)
# Increment metric counters
model_deletes.labels(instance._meta.model_name).inc()
#
# Caching
@@ -25,10 +93,3 @@ def cache_invalidated_collector(sender, obj_dict, **kwargs):
cache_read.connect(cache_read_collector)
cache_invalidated.connect(cache_invalidated_collector)
#
# Change logging
#
purge_changelog = Signal()

View File

@@ -35,10 +35,8 @@ OBJECTCHANGE_ACTION = """
"""
OBJECTCHANGE_OBJECT = """
{% if record.action != 3 and record.changed_object.get_absolute_url %}
{% if record.changed_object.get_absolute_url %}
<a href="{{ record.changed_object.get_absolute_url }}">{{ record.object_repr }}</a>
{% elif record.action != 3 and record.related_object.get_absolute_url %}
<a href="{{ record.related_object.get_absolute_url }}">{{ record.object_repr }}</a>
{% else %}
{{ record.object_repr }}
{% endif %}

View File

@@ -16,12 +16,12 @@ GROUP_BUTTON = '<div class="btn-group">\n' \
'{} <span class="caret"></span>\n' \
'</button>\n' \
'<ul class="dropdown-menu pull-right">\n' \
'{}</ul></div>'
'{}</ul></div>\n'
GROUP_LINK = '<li><a href="{}"{}>{}</a></li>\n'
@register.simple_tag()
def custom_links(obj):
@register.simple_tag(takes_context=True)
def custom_links(context, obj):
"""
Render all applicable links for the given object.
"""
@@ -30,8 +30,13 @@ def custom_links(obj):
if not custom_links:
return ''
context = {
# Pass select context data when rendering the CustomLink
link_context = {
'obj': obj,
'debug': context.get('debug', False), # django.template.context_processors.debug
'request': context['request'], # django.template.context_processors.request
'user': context['user'], # django.contrib.auth.context_processors.auth
'perms': context['perms'], # django.contrib.auth.context_processors.auth
}
template_code = ''
group_names = OrderedDict()
@@ -47,9 +52,9 @@ def custom_links(obj):
# Add non-grouped links
else:
try:
text_rendered = render_jinja2(cl.text, context)
text_rendered = render_jinja2(cl.text, link_context)
if text_rendered:
link_rendered = render_jinja2(cl.url, context)
link_rendered = render_jinja2(cl.url, link_context)
link_target = ' target="_blank"' if cl.new_window else ''
template_code += LINK_BUTTON.format(
link_rendered, link_target, cl.button_class, text_rendered
@@ -65,10 +70,10 @@ def custom_links(obj):
for cl in links:
try:
text_rendered = render_jinja2(cl.text, context)
text_rendered = render_jinja2(cl.text, link_context)
if text_rendered:
link_target = ' target="_blank"' if cl.new_window else ''
link_rendered = render_jinja2(cl.url, context)
link_rendered = render_jinja2(cl.url, link_context)
links_rendered.append(
GROUP_LINK.format(link_rendered, link_target, text_rendered)
)

View File

@@ -13,7 +13,7 @@ class SiteContent(PluginTemplateExtension):
def full_width_page(self):
return "SITE CONTENT - FULL WIDTH PAGE"
def full_buttons(self):
def buttons(self):
return "SITE CONTENT - BUTTONS"

View File

@@ -3,7 +3,7 @@ from unittest import skipIf
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from django.utils import timezone
from django.utils.timezone import make_aware
from django_rq.queues import get_connection
from rest_framework import status
from rq import Worker
@@ -369,8 +369,8 @@ class CreatedUpdatedFilterTest(APITestCase):
# change the created and last_updated of one
Rack.objects.filter(pk=self.rack2.pk).update(
last_updated=datetime.datetime(2001, 2, 3, 1, 2, 3, 4, tzinfo=timezone.utc),
created=datetime.datetime(2001, 2, 3)
last_updated=make_aware(datetime.datetime(2001, 2, 3, 1, 2, 3, 4)),
created=make_aware(datetime.datetime(2001, 2, 3))
)
def test_get_rack_created(self):

View File

@@ -2,13 +2,125 @@ from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from rest_framework import status
from dcim.choices import SiteStatusChoices
from dcim.models import Site
from extras.choices import *
from extras.models import CustomField, CustomFieldValue, ObjectChange
from extras.models import CustomField, CustomFieldValue, ObjectChange, Tag
from utilities.testing import APITestCase
from utilities.testing.utils import post_data
from utilities.testing.views import ModelViewTestCase
class ChangeLogTest(APITestCase):
class ChangeLogViewTest(ModelViewTestCase):
model = Site
@classmethod
def setUpTestData(cls):
# Create a custom field on the Site model
ct = ContentType.objects.get_for_model(Site)
cf = CustomField(
type=CustomFieldTypeChoices.TYPE_TEXT,
name='my_field',
required=False
)
cf.save()
cf.obj_type.set([ct])
def test_create_object(self):
tags = self.create_tags('Tag 1', 'Tag 2')
form_data = {
'name': 'Test Site 1',
'slug': 'test-site-1',
'status': SiteStatusChoices.STATUS_ACTIVE,
'cf_my_field': 'ABC',
'tags': [tag.pk for tag in tags],
}
request = {
'path': self._get_url('add'),
'data': post_data(form_data),
}
self.add_permissions('dcim.add_site', 'extras.view_tag')
response = self.client.post(**request)
self.assertHttpStatus(response, 302)
site = Site.objects.get(name='Test Site 1')
# First OC is the creation; second is the tags update
oc_list = ObjectChange.objects.filter(
changed_object_type=ContentType.objects.get_for_model(Site),
changed_object_id=site.pk
).order_by('pk')
self.assertEqual(oc_list[0].changed_object, site)
self.assertEqual(oc_list[0].action, ObjectChangeActionChoices.ACTION_CREATE)
self.assertEqual(oc_list[0].object_data['custom_fields']['my_field'], form_data['cf_my_field'])
self.assertEqual(oc_list[1].action, ObjectChangeActionChoices.ACTION_UPDATE)
self.assertEqual(oc_list[1].object_data['tags'], ['Tag 1', 'Tag 2'])
def test_update_object(self):
site = Site(name='Test Site 1', slug='test-site-1')
site.save()
tags = self.create_tags('Tag 1', 'Tag 2', 'Tag 3')
site.tags.set('Tag 1', 'Tag 2')
form_data = {
'name': 'Test Site X',
'slug': 'test-site-x',
'status': SiteStatusChoices.STATUS_PLANNED,
'cf_my_field': 'DEF',
'tags': [tags[2].pk],
}
request = {
'path': self._get_url('edit', instance=site),
'data': post_data(form_data),
}
self.add_permissions('dcim.change_site', 'extras.view_tag')
response = self.client.post(**request)
self.assertHttpStatus(response, 302)
site.refresh_from_db()
# Get only the most recent OC
oc = ObjectChange.objects.filter(
changed_object_type=ContentType.objects.get_for_model(Site),
changed_object_id=site.pk
).first()
self.assertEqual(oc.changed_object, site)
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE)
self.assertEqual(oc.object_data['custom_fields']['my_field'], form_data['cf_my_field'])
self.assertEqual(oc.object_data['tags'], ['Tag 3'])
def test_delete_object(self):
site = Site(
name='Test Site 1',
slug='test-site-1'
)
site.save()
self.create_tags('Tag 1', 'Tag 2')
site.tags.set('Tag 1', 'Tag 2')
CustomFieldValue.objects.create(
field=CustomField.objects.get(name='my_field'),
obj=site,
value='ABC'
)
request = {
'path': self._get_url('delete', instance=site),
'data': post_data({'confirm': True}),
}
self.add_permissions('dcim.delete_site')
response = self.client.post(**request)
self.assertHttpStatus(response, 302)
oc = ObjectChange.objects.first()
self.assertEqual(oc.changed_object, None)
self.assertEqual(oc.object_repr, site.name)
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
self.assertEqual(oc.object_data['custom_fields']['my_field'], 'ABC')
self.assertEqual(oc.object_data['tags'], ['Tag 1', 'Tag 2'])
class ChangeLogAPITest(APITestCase):
def setUp(self):
super().setUp()
@@ -23,6 +135,14 @@ class ChangeLogTest(APITestCase):
cf.save()
cf.obj_type.set([ct])
# Create some tags
tags = (
Tag(name='Tag 1', slug='tag-1'),
Tag(name='Tag 2', slug='tag-2'),
Tag(name='Tag 3', slug='tag-3'),
)
Tag.objects.bulk_create(tags)
def test_create_object(self):
data = {
'name': 'Test Site 1',
@@ -30,6 +150,10 @@ class ChangeLogTest(APITestCase):
'custom_fields': {
'my_field': 'ABC'
},
'tags': [
{'name': 'Tag 1'},
{'name': 'Tag 2'},
]
}
self.assertEqual(ObjectChange.objects.count(), 0)
url = reverse('dcim-api:site-list')
@@ -39,13 +163,16 @@ class ChangeLogTest(APITestCase):
self.assertHttpStatus(response, status.HTTP_201_CREATED)
site = Site.objects.get(pk=response.data['id'])
oc = ObjectChange.objects.get(
# First OC is the creation; second is the tags update
oc_list = ObjectChange.objects.filter(
changed_object_type=ContentType.objects.get_for_model(Site),
changed_object_id=site.pk
)
self.assertEqual(oc.changed_object, site)
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_CREATE)
self.assertEqual(oc.object_data['custom_fields'], data['custom_fields'])
).order_by('pk')
self.assertEqual(oc_list[0].changed_object, site)
self.assertEqual(oc_list[0].action, ObjectChangeActionChoices.ACTION_CREATE)
self.assertEqual(oc_list[0].object_data['custom_fields'], data['custom_fields'])
self.assertEqual(oc_list[1].action, ObjectChangeActionChoices.ACTION_UPDATE)
self.assertEqual(oc_list[1].object_data['tags'], ['Tag 1', 'Tag 2'])
def test_update_object(self):
site = Site(name='Test Site 1', slug='test-site-1')
@@ -57,6 +184,9 @@ class ChangeLogTest(APITestCase):
'custom_fields': {
'my_field': 'DEF'
},
'tags': [
{'name': 'Tag 3'}
]
}
self.assertEqual(ObjectChange.objects.count(), 0)
self.add_permissions('dcim.change_site')
@@ -66,13 +196,15 @@ class ChangeLogTest(APITestCase):
self.assertHttpStatus(response, status.HTTP_200_OK)
site = Site.objects.get(pk=response.data['id'])
oc = ObjectChange.objects.get(
# Get only the most recent OC
oc = ObjectChange.objects.filter(
changed_object_type=ContentType.objects.get_for_model(Site),
changed_object_id=site.pk
)
).first()
self.assertEqual(oc.changed_object, site)
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE)
self.assertEqual(oc.object_data['custom_fields'], data['custom_fields'])
self.assertEqual(oc.object_data['tags'], ['Tag 3'])
def test_delete_object(self):
site = Site(
@@ -80,6 +212,7 @@ class ChangeLogTest(APITestCase):
slug='test-site-1'
)
site.save()
site.tags.set(*Tag.objects.all()[:2])
CustomFieldValue.objects.create(
field=CustomField.objects.get(name='my_field'),
obj=site,
@@ -98,3 +231,4 @@ class ChangeLogTest(APITestCase):
self.assertEqual(oc.object_repr, site.name)
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
self.assertEqual(oc.object_data['custom_fields'], {'my_field': 'ABC'})
self.assertEqual(oc.object_data['tags'], ['Tag 1', 'Tag 2'])

View File

@@ -1,11 +1,11 @@
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
from dcim.models import DeviceRole, Platform, Region, Site
from dcim.models import DeviceRole, Platform, Rack, Region, Site
from extras.choices import *
from extras.filters import *
from extras.utils import FeatureQuery
from extras.models import ConfigContext, ExportTemplate, Graph, Tag
from extras.models import ConfigContext, ExportTemplate, Graph, ImageAttachment, Tag
from tenancy.models import Tenant, TenantGroup
from virtualization.models import Cluster, ClusterGroup, ClusterType
@@ -78,6 +78,84 @@ class ExportTemplateTestCase(TestCase):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class ImageAttachmentTestCase(TestCase):
queryset = ImageAttachment.objects.all()
filterset = ImageAttachmentFilterSet
@classmethod
def setUpTestData(cls):
site_ct = ContentType.objects.get(app_label='dcim', model='site')
rack_ct = ContentType.objects.get(app_label='dcim', model='rack')
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
)
Site.objects.bulk_create(sites)
racks = (
Rack(name='Rack 1', site=sites[0]),
Rack(name='Rack 2', site=sites[1]),
)
Rack.objects.bulk_create(racks)
image_attachments = (
ImageAttachment(
content_type=site_ct,
object_id=sites[0].pk,
name='Image Attachment 1',
image='http://example.com/image1.png',
image_height=100,
image_width=100
),
ImageAttachment(
content_type=site_ct,
object_id=sites[1].pk,
name='Image Attachment 2',
image='http://example.com/image2.png',
image_height=100,
image_width=100
),
ImageAttachment(
content_type=rack_ct,
object_id=racks[0].pk,
name='Image Attachment 3',
image='http://example.com/image3.png',
image_height=100,
image_width=100
),
ImageAttachment(
content_type=rack_ct,
object_id=racks[1].pk,
name='Image Attachment 4',
image='http://example.com/image4.png',
image_height=100,
image_width=100
)
)
ImageAttachment.objects.bulk_create(image_attachments)
def test_id(self):
params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_name(self):
params = {'name': ['Image Attachment 1', 'Image Attachment 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_content_type(self):
params = {'content_type': ContentType.objects.get(app_label='dcim', model='site').pk}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_content_type_and_object_id(self):
params = {
'content_type': ContentType.objects.get(app_label='dcim', model='site').pk,
'object_id': [Site.objects.first().pk],
}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
class ConfigContextTestCase(TestCase):
queryset = ConfigContext.objects.all()
filterset = ConfigContextFilterSet

View File

@@ -1,9 +1,11 @@
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
from dcim.models import Site
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Site, Region
from extras.choices import TemplateLanguageChoices
from extras.models import Graph, Tag
from extras.models import ConfigContext, Graph, Tag
from tenancy.models import Tenant, TenantGroup
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
class GraphTest(TestCase):
@@ -53,3 +55,354 @@ class TagTest(TestCase):
tag.save()
self.assertEqual(tag.slug, 'testing-unicode-台灣')
class ConfigContextTest(TestCase):
"""
These test cases deal with the weighting, ordering, and deep merge logic of config context data.
It also ensures the various config context querysets are consistent.
"""
def setUp(self):
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
self.devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
self.devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
self.region = Region.objects.create(name="Region")
self.site = Site.objects.create(name='Site-1', slug='site-1', region=self.region)
self.platform = Platform.objects.create(name="Platform")
self.tenantgroup = TenantGroup.objects.create(name="Tenant Group")
self.tenant = Tenant.objects.create(name="Tenant", group=self.tenantgroup)
self.tag = Tag.objects.create(name="Tag", slug="tag")
self.tag2 = Tag.objects.create(name="Tag2", slug="tag2")
self.device = Device.objects.create(
name='Device 1',
device_type=self.devicetype,
device_role=self.devicerole,
site=self.site
)
def test_higher_weight_wins(self):
context1 = ConfigContext(
name="context 1",
weight=101,
data={
"a": 123,
"b": 456,
"c": 777
}
)
context2 = ConfigContext(
name="context 2",
weight=100,
data={
"a": 123,
"b": 456,
"c": 789
}
)
ConfigContext.objects.bulk_create([context1, context2])
expected_data = {
"a": 123,
"b": 456,
"c": 777
}
self.assertEqual(self.device.get_config_context(), expected_data)
def test_name_ordering_after_weight(self):
context1 = ConfigContext(
name="context 1",
weight=100,
data={
"a": 123,
"b": 456,
"c": 777
}
)
context2 = ConfigContext(
name="context 2",
weight=100,
data={
"a": 123,
"b": 456,
"c": 789
}
)
ConfigContext.objects.bulk_create([context1, context2])
expected_data = {
"a": 123,
"b": 456,
"c": 789
}
self.assertEqual(self.device.get_config_context(), expected_data)
def test_annotation_same_as_get_for_object(self):
"""
This test incorperates features from all of the above tests cases to ensure
the annotate_config_context_data() and get_for_object() queryset methods are the same.
"""
context1 = ConfigContext(
name="context 1",
weight=101,
data={
"a": 123,
"b": 456,
"c": 777
}
)
context2 = ConfigContext(
name="context 2",
weight=100,
data={
"a": 123,
"b": 456,
"c": 789
}
)
context3 = ConfigContext(
name="context 3",
weight=99,
data={
"d": 1
}
)
context4 = ConfigContext(
name="context 4",
weight=99,
data={
"d": 2
}
)
ConfigContext.objects.bulk_create([context1, context2, context3, context4])
annotated_queryset = Device.objects.filter(name=self.device.name).annotate_config_context_data()
self.assertEqual(self.device.get_config_context(), annotated_queryset[0].get_config_context())
def test_annotation_same_as_get_for_object_device_relations(self):
site_context = ConfigContext.objects.create(
name="site",
weight=100,
data={
"site": 1
}
)
site_context.sites.add(self.site)
region_context = ConfigContext.objects.create(
name="region",
weight=100,
data={
"region": 1
}
)
region_context.regions.add(self.region)
platform_context = ConfigContext.objects.create(
name="platform",
weight=100,
data={
"platform": 1
}
)
platform_context.platforms.add(self.platform)
tenant_group_context = ConfigContext.objects.create(
name="tenant group",
weight=100,
data={
"tenant_group": 1
}
)
tenant_group_context.tenant_groups.add(self.tenantgroup)
tenant_context = ConfigContext.objects.create(
name="tenant",
weight=100,
data={
"tenant": 1
}
)
tenant_context.tenants.add(self.tenant)
tag_context = ConfigContext.objects.create(
name="tag",
weight=100,
data={
"tag": 1
}
)
tag_context.tags.add(self.tag)
device = Device.objects.create(
name="Device 2",
site=self.site,
tenant=self.tenant,
platform=self.platform,
device_role=self.devicerole,
device_type=self.devicetype
)
device.tags.add(self.tag)
annotated_queryset = Device.objects.filter(name=device.name).annotate_config_context_data()
self.assertEqual(device.get_config_context(), annotated_queryset[0].get_config_context())
def test_annotation_same_as_get_for_object_virtualmachine_relations(self):
site_context = ConfigContext.objects.create(
name="site",
weight=100,
data={
"site": 1
}
)
site_context.sites.add(self.site)
region_context = ConfigContext.objects.create(
name="region",
weight=100,
data={
"region": 1
}
)
region_context.regions.add(self.region)
platform_context = ConfigContext.objects.create(
name="platform",
weight=100,
data={
"platform": 1
}
)
platform_context.platforms.add(self.platform)
tenant_group_context = ConfigContext.objects.create(
name="tenant group",
weight=100,
data={
"tenant_group": 1
}
)
tenant_group_context.tenant_groups.add(self.tenantgroup)
tenant_context = ConfigContext.objects.create(
name="tenant",
weight=100,
data={
"tenant": 1
}
)
tenant_context.tenants.add(self.tenant)
tag_context = ConfigContext.objects.create(
name="tag",
weight=100,
data={
"tag": 1
}
)
tag_context.tags.add(self.tag)
cluster_group = ClusterGroup.objects.create(name="Cluster Group")
cluster_group_context = ConfigContext.objects.create(
name="cluster group",
weight=100,
data={
"cluster_group": 1
}
)
cluster_group_context.cluster_groups.add(cluster_group)
cluster_type = ClusterType.objects.create(name="Cluster Type 1")
cluster = Cluster.objects.create(name="Cluster", group=cluster_group, type=cluster_type)
cluster_context = ConfigContext.objects.create(
name="cluster",
weight=100,
data={
"cluster": 1
}
)
cluster_context.clusters.add(cluster)
virtual_machine = VirtualMachine.objects.create(
name="VM 1",
cluster=cluster,
tenant=self.tenant,
platform=self.platform,
role=self.devicerole
)
virtual_machine.tags.add(self.tag)
annotated_queryset = VirtualMachine.objects.filter(name=virtual_machine.name).annotate_config_context_data()
self.assertEqual(virtual_machine.get_config_context(), annotated_queryset[0].get_config_context())
def test_multiple_tags_return_distinct_objects(self):
"""
Tagged items use a generic relationship, which results in duplicate rows being returned when queried.
This is combatted by by appending distinct() to the config context querysets. This test creates a config
context assigned to two tags and ensures objects related by those same two tags result in only a single
config context record being returned.
See https://github.com/netbox-community/netbox/issues/5314
"""
tag_context = ConfigContext.objects.create(
name="tag",
weight=100,
data={
"tag": 1
}
)
tag_context.tags.add(self.tag)
tag_context.tags.add(self.tag2)
device = Device.objects.create(
name="Device 3",
site=self.site,
tenant=self.tenant,
platform=self.platform,
device_role=self.devicerole,
device_type=self.devicetype
)
device.tags.add(self.tag)
device.tags.add(self.tag2)
annotated_queryset = Device.objects.filter(name=device.name).annotate_config_context_data()
self.assertEqual(ConfigContext.objects.get_for_object(device).count(), 1)
self.assertEqual(device.get_config_context(), annotated_queryset[0].get_config_context())
def test_multiple_tags_return_distinct_objects_with_seperate_config_contexts(self):
"""
Tagged items use a generic relationship, which results in duplicate rows being returned when queried.
This is combatted by by appending distinct() to the config context querysets. This test creates a config
context assigned to two tags and ensures objects related by those same two tags result in only a single
config context record being returned.
This test case is seperate from the above in that it deals with multiple config context objects in play.
See https://github.com/netbox-community/netbox/issues/5387
"""
tag_context_1 = ConfigContext.objects.create(
name="tag-1",
weight=100,
data={
"tag": 1
}
)
tag_context_1.tags.add(self.tag)
tag_context_2 = ConfigContext.objects.create(
name="tag-2",
weight=100,
data={
"tag": 1
}
)
tag_context_2.tags.add(self.tag2)
device = Device.objects.create(
name="Device 3",
site=self.site,
tenant=self.tenant,
platform=self.platform,
device_role=self.devicerole,
device_type=self.devicetype
)
device.tags.add(self.tag)
device.tags.add(self.tag2)
annotated_queryset = Device.objects.filter(name=device.name).annotate_config_context_data()
self.assertEqual(ConfigContext.objects.get_for_object(device).count(), 2)
self.assertEqual(device.get_config_context(), annotated_queryset[0].get_config_context())

View File

@@ -86,21 +86,19 @@ class PluginTest(TestCase):
"""
self.assertIn('extras.tests.dummy_plugin.*', settings.CACHEOPS)
@override_settings(VERSION='0.9')
def test_min_version(self):
"""
Check enforcement of minimum NetBox version.
"""
with self.assertRaises(ImproperlyConfigured):
dummy_config.validate({})
dummy_config.validate({}, '0.9')
@override_settings(VERSION='10.0')
def test_max_version(self):
"""
Check enforcement of maximum NetBox version.
"""
with self.assertRaises(ImproperlyConfigured):
dummy_config.validate({})
dummy_config.validate({}, '10.0')
def test_required_settings(self):
"""
@@ -110,11 +108,11 @@ class PluginTest(TestCase):
required_settings = ['foo']
# Validation should pass when all required settings are present
DummyConfigWithRequiredSettings.validate({'foo': True})
DummyConfigWithRequiredSettings.validate({'foo': True}, settings.VERSION)
# Validation should fail when a required setting is missing
with self.assertRaises(ImproperlyConfigured):
DummyConfigWithRequiredSettings.validate({})
DummyConfigWithRequiredSettings.validate({}, settings.VERSION)
def test_default_settings(self):
"""
@@ -127,10 +125,10 @@ class PluginTest(TestCase):
# Populate the default value if setting has not been specified
user_config = {}
DummyConfigWithDefaultSettings.validate(user_config)
DummyConfigWithDefaultSettings.validate(user_config, settings.VERSION)
self.assertEqual(user_config['bar'], 123)
# Don't overwrite specified values
user_config = {'bar': 456}
DummyConfigWithDefaultSettings.validate(user_config)
DummyConfigWithDefaultSettings.validate(user_config, settings.VERSION)
self.assertEqual(user_config['bar'], 456)

View File

@@ -152,10 +152,7 @@ class ScriptVariablesTest(TestCase):
def test_objectvar(self):
class TestScript(Script):
var1 = ObjectVar(
queryset=DeviceRole.objects.all()
)
var1 = ObjectVar(model=DeviceRole)
# Populate some objects
for i in range(1, 6):
@@ -173,10 +170,7 @@ class ScriptVariablesTest(TestCase):
def test_multiobjectvar(self):
class TestScript(Script):
var1 = MultiObjectVar(
queryset=DeviceRole.objects.all()
)
var1 = MultiObjectVar(model=DeviceRole)
# Populate some objects
for i in range(1, 6):

View File

@@ -59,3 +59,21 @@ class TaggedItemTest(APITestCase):
sorted([t.name for t in site.tags.all()]),
sorted(["Foo", "Bar", "New Tag"])
)
def test_clear_tagged_item(self):
site = Site.objects.create(
name='Test Site',
slug='test-site'
)
site.tags.add("Foo", "Bar", "Baz")
data = {
'tags': []
}
self.add_permissions('dcim.change_site')
url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(len(response.data['tags']), 0)
site = Site.objects.get(pk=response.data['id'])
self.assertEqual(len(site.tags.all()), 0)

View File

@@ -2,11 +2,13 @@ import urllib.parse
import uuid
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.test import override_settings
from django.urls import reverse
from dcim.models import Site
from extras.choices import ObjectChangeActionChoices
from extras.models import ConfigContext, ObjectChange, Tag
from extras.models import ConfigContext, CustomLink, ObjectChange, Tag
from utilities.testing import ViewTestCases, TestCase
@@ -124,3 +126,24 @@ class ObjectChangeTestCase(TestCase):
objectchange = ObjectChange.objects.first()
response = self.client.get(objectchange.get_absolute_url())
self.assertHttpStatus(response, 200)
class CustomLinkTest(TestCase):
user_permissions = ['dcim.view_site']
def test_view_object_with_custom_link(self):
customlink = CustomLink(
content_type=ContentType.objects.get_for_model(Site),
name='Test',
text='FOO {{ obj.name }} BAR',
url='http://example.com/?site={{ obj.slug }}',
new_window=False
)
customlink.save()
site = Site(name='Test Site', slug='test-site')
site.save()
response = self.client.get(site.get_absolute_url(), follow=True)
self.assertEqual(response.status_code, 200)
self.assertIn(f'FOO {site.name} BAR', str(response.content))

View File

@@ -3,7 +3,6 @@ import collections
from django.db.models import Q
from django.utils.deconstruct import deconstructible
from taggit.managers import _TaggableManager
from utilities.querysets import DummyQuerySet
from extras.constants import EXTRAS_FEATURES
from extras.registry import registry
@@ -16,9 +15,6 @@ def is_taggable(obj):
if hasattr(obj, 'tags'):
if issubclass(obj.tags.__class__, _TaggableManager):
return True
# TaggableManager has been replaced with a DummyQuerySet prior to object deletion
if isinstance(obj.tags, DummyQuerySet):
return True
return False

View File

@@ -1,8 +1,7 @@
from django import template
from django.conf import settings
from django.contrib import messages
from django.contrib.contenttypes.models import ContentType
from django.db.models import Count, Prefetch, Q
from django.db.models import Prefetch, Q
from django.http import Http404, HttpResponseForbidden
from django.shortcuts import get_object_or_404, redirect, render
from django.views.generic import View
@@ -13,8 +12,8 @@ from rq import Worker
from dcim.models import DeviceRole, Platform, Region, Site
from tenancy.models import Tenant, TenantGroup
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator
from utilities.utils import copy_safe_request, shallow_compare_dict
from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.utils import copy_safe_request, get_subquery, shallow_compare_dict
from utilities.views import (
BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
ContentTypePermissionRequiredMixin,
@@ -22,7 +21,7 @@ from utilities.views import (
from virtualization.models import Cluster, ClusterGroup
from . import filters, forms, tables
from .choices import JobResultStatusChoices
from .models import ConfigContext, ImageAttachment, ObjectChange, JobResult, Tag
from .models import ConfigContext, ImageAttachment, ObjectChange, JobResult, Tag, TaggedItem
from .reports import get_report, get_reports, run_report
from .scripts import get_scripts, run_script
@@ -33,8 +32,8 @@ from .scripts import get_scripts, run_script
class TagListView(ObjectListView):
queryset = Tag.objects.annotate(
items=Count('extras_taggeditem_items')
).order_by(*Tag._meta.ordering)
items=get_subquery(TaggedItem, 'tag')
)
filterset = filters.TagFilterSet
filterset_form = forms.TagFilterForm
table = tables.TagTable
@@ -58,16 +57,16 @@ class TagBulkImportView(BulkImportView):
class TagBulkEditView(BulkEditView):
queryset = Tag.objects.annotate(
items=Count('extras_taggeditem_items')
).order_by(*Tag._meta.ordering)
items=get_subquery(TaggedItem, 'tag')
)
table = tables.TagTable
form = forms.TagBulkEditForm
class TagBulkDeleteView(BulkDeleteView):
queryset = Tag.objects.annotate(
items=Count('extras_taggeditem_items')
).order_by(*Tag._meta.ordering)
items=get_subquery(TaggedItem, 'tag')
)
table = tables.TagTable
@@ -258,7 +257,7 @@ class ObjectChangeLogView(View):
# Apply the request context
paginate = {
'paginator_class': EnhancedPaginator,
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
'per_page': get_paginate_count(request)
}
RequestConfig(request, paginate).configure(objectchanges_table)
@@ -315,7 +314,7 @@ class ReportListView(ContentTypePermissionRequiredMixin, View):
Retrieve all of the available reports from disk and the recorded JobResult (if any) for each.
"""
def get_required_permission(self):
return 'extras.view_reportresult'
return 'extras.view_report'
def get(self, request):
@@ -347,7 +346,7 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
Display a single Report and its associated JobResult (if any).
"""
def get_required_permission(self):
return 'extras.view_reportresult'
return 'extras.view_report'
def get(self, request, module, name):

View File

@@ -5,10 +5,10 @@ from django.contrib.contenttypes.models import ContentType
from django.utils import timezone
from django_rq import get_queue
from extras.models import Webhook
from utilities.api import get_serializer_for_model
from .choices import *
from .utils import FeatureQuery
from .models import Webhook
from .registry import registry
def generate_signature(request_body, secret):
@@ -28,13 +28,14 @@ def enqueue_webhooks(instance, user, request_id, action):
Find Webhook(s) assigned to this instance + action and enqueue them
to be processed
"""
obj_type = ContentType.objects.get_for_model(instance.__class__)
webhook_models = ContentType.objects.filter(FeatureQuery('webhooks').get_query())
if obj_type not in webhook_models:
# Determine whether this type of object supports webhooks
app_label = instance._meta.app_label
model_name = instance._meta.model_name
if model_name not in registry['model_features']['webhooks'].get(app_label, []):
return
# Retrieve any applicable Webhooks
obj_type = ContentType.objects.get_for_model(instance)
action_flag = {
ObjectChangeActionChoices.ACTION_CREATE: 'type_create',
ObjectChangeActionChoices.ACTION_UPDATE: 'type_update',

View File

@@ -219,7 +219,8 @@ class IPAddressSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
role = ChoiceField(choices=IPAddressRoleChoices, allow_blank=True, required=False)
assigned_object_type = ContentTypeField(
queryset=ContentType.objects.filter(IPADDRESS_ASSIGNMENT_MODELS),
required=False
required=False,
allow_null=True
)
assigned_object = serializers.SerializerMethodField(read_only=True)
nat_inside = NestedIPAddressSerializer(required=False, allow_null=True)

View File

@@ -1,5 +1,4 @@
from django.conf import settings
from django.db.models import Count
from django.shortcuts import get_object_or_404
from django_pglocks import advisory_lock
from drf_yasg.utils import swagger_auto_schema
@@ -33,7 +32,7 @@ class VRFViewSet(CustomFieldModelViewSet):
queryset = VRF.objects.prefetch_related('tenant').prefetch_related('tags').annotate(
ipaddress_count=get_subquery(IPAddress, 'vrf'),
prefix_count=get_subquery(Prefix, 'vrf')
).order_by(*VRF._meta.ordering)
)
serializer_class = serializers.VRFSerializer
filterset_class = filters.VRFFilterSet
@@ -44,8 +43,8 @@ class VRFViewSet(CustomFieldModelViewSet):
class RIRViewSet(ModelViewSet):
queryset = RIR.objects.annotate(
aggregate_count=Count('aggregates')
).order_by(*RIR._meta.ordering)
aggregate_count=get_subquery(Aggregate, 'rir')
)
serializer_class = serializers.RIRSerializer
filterset_class = filters.RIRFilterSet
@@ -68,7 +67,7 @@ class RoleViewSet(ModelViewSet):
queryset = Role.objects.annotate(
prefix_count=get_subquery(Prefix, 'role'),
vlan_count=get_subquery(VLAN, 'role')
).order_by(*Role._meta.ordering)
)
serializer_class = serializers.RoleSerializer
filterset_class = filters.RoleFilterSet
@@ -78,7 +77,9 @@ class RoleViewSet(ModelViewSet):
#
class PrefixViewSet(CustomFieldModelViewSet):
queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role', 'tags')
queryset = Prefix.objects.prefetch_related(
'site', 'vrf__tenant', 'tenant', 'vlan', 'role', 'tags'
)
serializer_class = serializers.PrefixSerializer
filterset_class = filters.PrefixFilterSet
@@ -88,7 +89,7 @@ class PrefixViewSet(CustomFieldModelViewSet):
return super().get_serializer_class()
@swagger_auto_schema(method='get', responses={200: serializers.AvailablePrefixSerializer(many=True)})
@swagger_auto_schema(method='post', responses={201: serializers.AvailablePrefixSerializer(many=True)})
@swagger_auto_schema(method='post', responses={201: serializers.PrefixSerializer(many=False)})
@action(detail=True, url_path='available-prefixes', methods=['get', 'post'])
@advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes'])
def available_prefixes(self, request, pk=None):
@@ -247,7 +248,7 @@ class PrefixViewSet(CustomFieldModelViewSet):
class IPAddressViewSet(CustomFieldModelViewSet):
queryset = IPAddress.objects.prefetch_related(
'vrf__tenant', 'tenant', 'nat_inside', 'nat_outside', 'tags',
'vrf__tenant', 'tenant', 'nat_inside', 'nat_outside', 'tags', 'assigned_object'
)
serializer_class = serializers.IPAddressSerializer
filterset_class = filters.IPAddressFilterSet
@@ -259,8 +260,8 @@ class IPAddressViewSet(CustomFieldModelViewSet):
class VLANGroupViewSet(ModelViewSet):
queryset = VLANGroup.objects.prefetch_related('site').annotate(
vlan_count=Count('vlans')
).order_by(*VLANGroup._meta.ordering)
vlan_count=get_subquery(VLAN, 'group')
)
serializer_class = serializers.VLANGroupSerializer
filterset_class = filters.VLANGroupFilterSet
@@ -274,7 +275,7 @@ class VLANViewSet(CustomFieldModelViewSet):
'site', 'group', 'tenant', 'role', 'tags'
).annotate(
prefix_count=get_subquery(Prefix, 'vlan')
).order_by(*VLAN._meta.ordering)
)
serializer_class = serializers.VLANSerializer
filterset_class = filters.VLANFilterSet

View File

@@ -41,12 +41,14 @@ class IPAddressStatusChoices(ChoiceSet):
STATUS_RESERVED = 'reserved'
STATUS_DEPRECATED = 'deprecated'
STATUS_DHCP = 'dhcp'
STATUS_SLAAC = 'slaac'
CHOICES = (
(STATUS_ACTIVE, 'Active'),
(STATUS_RESERVED, 'Reserved'),
(STATUS_DEPRECATED, 'Deprecated'),
(STATUS_DHCP, 'DHCP'),
(STATUS_SLAAC, 'SLAAC'),
)

View File

@@ -253,10 +253,20 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
label='VRF',
display_field='display_name'
)
region = DynamicModelChoiceField(
queryset=Region.objects.all(),
required=False,
initial_params={
'sites': '$site'
}
)
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False,
null_option='None'
null_option='None',
query_params={
'region_id': '$region'
}
)
vlan_group = DynamicModelChoiceField(
queryset=VLANGroup.objects.all(),
@@ -265,6 +275,9 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
null_option='None',
query_params={
'site_id': '$site'
},
initial_params={
'vlans': '$vlan'
}
)
vlan = DynamicModelChoiceField(
@@ -297,14 +310,6 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
}
def __init__(self, *args, **kwargs):
# Initialize helper selectors
instance = kwargs.get('instance')
initial = kwargs.get('initial', {}).copy()
if instance and instance.vlan is not None:
initial['vlan_group'] = instance.vlan.group
kwargs['initial'] = initial
super().__init__(*args, **kwargs)
self.fields['vrf'].empty_label = 'Global'
@@ -374,9 +379,17 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
queryset=Prefix.objects.all(),
widget=forms.MultipleHiddenInput()
)
region = DynamicModelChoiceField(
queryset=Region.objects.all(),
required=False,
to_field_name='slug'
)
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False
required=False,
query_params={
'region': '$region'
}
)
vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(),
@@ -501,7 +514,10 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
device = DynamicModelChoiceField(
queryset=Device.objects.all(),
required=False,
display_field='display_name'
display_field='display_name',
initial_params={
'interfaces': '$interface'
}
)
interface = DynamicModelChoiceField(
queryset=Interface.objects.all(),
@@ -512,7 +528,10 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
)
virtual_machine = DynamicModelChoiceField(
queryset=VirtualMachine.objects.all(),
required=False
required=False,
initial_params={
'interfaces': '$vminterface'
}
)
vminterface = DynamicModelChoiceField(
queryset=VMInterface.objects.all(),
@@ -528,10 +547,21 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
label='VRF',
display_field='display_name'
)
nat_region = DynamicModelChoiceField(
queryset=Region.objects.all(),
required=False,
label='Region',
initial_params={
'sites': '$nat_site'
}
)
nat_site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False,
label='Site'
label='Site',
query_params={
'region_id': '$nat_region'
}
)
nat_rack = DynamicModelChoiceField(
queryset=Rack.objects.all(),
@@ -611,15 +641,19 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
initial = kwargs.get('initial', {}).copy()
if instance:
if type(instance.assigned_object) is Interface:
initial['device'] = instance.assigned_object.device
initial['interface'] = instance.assigned_object
elif type(instance.assigned_object) is VMInterface:
initial['virtual_machine'] = instance.assigned_object.virtual_machine
initial['vminterface'] = instance.assigned_object
if instance.nat_inside and instance.nat_inside.device is not None:
initial['nat_site'] = instance.nat_inside.device.site
initial['nat_rack'] = instance.nat_inside.device.rack
initial['nat_device'] = instance.nat_inside.device
if instance.nat_inside:
nat_inside_parent = instance.nat_inside.assigned_object
if type(nat_inside_parent) is Interface:
initial['nat_site'] = nat_inside_parent.device.site.pk
if nat_inside_parent.device.rack:
initial['nat_rack'] = nat_inside_parent.device.rack.pk
initial['nat_device'] = nat_inside_parent.device.pk
elif type(nat_inside_parent) is VMInterface:
initial['nat_cluster'] = nat_inside_parent.virtual_machine.cluster.pk
initial['nat_virtual_machine'] = nat_inside_parent.virtual_machine.pk
kwargs['initial'] = initial
super().__init__(*args, **kwargs)
@@ -636,11 +670,11 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
self.initial['primary_for_parent'] = True
def clean(self):
super().clean()
# Cannot select both a device interface and a VM interface
if self.cleaned_data.get('interface') and self.cleaned_data.get('vminterface'):
raise forms.ValidationError("Cannot select both a device interface and a virtual machine interface")
self.instance.assigned_object = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface')
# Primary IP assignment is only available if an interface has been assigned.
interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface')
@@ -650,26 +684,21 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
)
def save(self, *args, **kwargs):
# Set assigned object
interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface')
if interface:
self.instance.assigned_object = interface
ipaddress = super().save(*args, **kwargs)
# Assign/clear this IPAddress as the primary for the associated Device/VirtualMachine.
interface = self.instance.assigned_object
if interface and self.cleaned_data['primary_for_parent']:
if ipaddress.address.version == 4:
interface.parent.primary_ip4 = ipaddress
else:
interface.primary_ip6 = ipaddress
interface.parent.primary_ip6 = ipaddress
interface.parent.save()
elif interface and ipaddress.address.version == 4 and interface.parent.primary_ip4 == ipaddress:
interface.parent.primary_ip4 = None
interface.parent.save()
elif interface and ipaddress.address.version == 6 and interface.parent.primary_ip6 == ipaddress:
interface.parent.primary_ip4 = None
interface.parent.primary_ip6 = None
interface.parent.save()
return ipaddress
@@ -925,16 +954,26 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo
#
class VLANGroupForm(BootstrapMixin, forms.ModelForm):
region = DynamicModelChoiceField(
queryset=Region.objects.all(),
required=False,
initial_params={
'sites': '$site'
}
)
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False
required=False,
query_params={
'region_id': '$region'
}
)
slug = SlugField()
class Meta:
model = VLANGroup
fields = [
'site', 'name', 'slug', 'description',
'region', 'site', 'name', 'slug', 'description',
]
@@ -974,10 +1013,20 @@ class VLANGroupFilterForm(BootstrapMixin, forms.Form):
#
class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
region = DynamicModelChoiceField(
queryset=Region.objects.all(),
required=False,
initial_params={
'sites': '$site'
}
)
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False,
null_option='None'
null_option='None',
query_params={
'region_id': '$region'
}
)
group = DynamicModelChoiceField(
queryset=VLANGroup.objects.all(),
@@ -1066,9 +1115,17 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
queryset=VLAN.objects.all(),
widget=forms.MultipleHiddenInput()
)
region = DynamicModelChoiceField(
queryset=Region.objects.all(),
required=False,
to_field_name='slug'
)
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False
required=False,
query_params={
'region': '$region'
}
)
group = DynamicModelChoiceField(
queryset=VLANGroup.objects.all(),

View File

@@ -107,7 +107,7 @@ class VRF(ChangeLoggedModel, CustomFieldModel):
@property
def display_name(self):
if self.rd:
return "{} ({})".format(self.name, self.rd)
return f'{self.name} ({self.rd})'
return self.name
@@ -669,6 +669,7 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
'reserved': 'info',
'deprecated': 'danger',
'dhcp': 'success',
'slaac': 'success',
}
ROLE_CLASS_MAP = {
@@ -725,32 +726,26 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
})
# Check for primary IP assignment that doesn't match the assigned device/VM
if self.pk and type(self.assigned_object) is Interface:
if self.pk:
device = Device.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first()
if device:
if self.assigned_object is None:
if getattr(self.assigned_object, 'device', None) != device:
raise ValidationError({
'interface': f"IP address is primary for device {device} but not assigned to an interface"
'interface': f"IP address is primary for device {device} but not assigned to it!"
})
elif self.assigned_object.device != device:
raise ValidationError({
'interface': f"IP address is primary for device {device} but assigned to "
f"{self.assigned_object.device} ({self.assigned_object})"
})
elif self.pk and type(self.assigned_object) is VMInterface:
vm = VirtualMachine.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first()
if vm:
if self.assigned_object is None:
if getattr(self.assigned_object, 'virtual_machine', None) != vm:
raise ValidationError({
'vminterface': f"IP address is primary for virtual machine {vm} but not assigned to an "
f"interface"
})
elif self.interface.virtual_machine != vm:
raise ValidationError({
'vminterface': f"IP address is primary for virtual machine {vm} but assigned to "
f"{self.assigned_object.virtual_machine} ({self.assigned_object})"
'vminterface': f"IP address is primary for virtual machine {vm} but not assigned to it!"
})
# Validate IP status selection
if self.status == IPAddressStatusChoices.STATUS_SLAAC and self.family != 6:
raise ValidationError({
'status': "Only IPv6 addresses can be assigned SLAAC status"
})
def save(self, *args, **kwargs):
# Force dns_name to lowercase
@@ -985,20 +980,25 @@ class VLAN(ChangeLoggedModel, CustomFieldModel):
@property
def display_name(self):
if self.vid and self.name:
return "{} ({})".format(self.vid, self.name)
return None
return f'{self.name} ({self.vid})'
def get_status_class(self):
return self.STATUS_CLASS_MAP[self.status]
def get_members(self):
# Return all interfaces assigned to this VLAN
def get_interfaces(self):
# Return all device interfaces assigned to this VLAN
return Interface.objects.filter(
Q(untagged_vlan_id=self.pk) |
Q(tagged_vlans=self.pk)
).distinct()
def get_vminterfaces(self):
# Return all VM interfaces assigned to this VLAN
return VMInterface.objects.filter(
Q(untagged_vlan_id=self.pk) |
Q(tagged_vlans=self.pk)
).distinct()
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class Service(ChangeLoggedModel, CustomFieldModel):

View File

@@ -4,6 +4,7 @@ from django_tables2.utils import Accessor
from dcim.models import Interface
from tenancy.tables import COL_TENANT
from utilities.tables import BaseTable, BooleanColumn, ButtonsColumn, TagColumn, ToggleColumn
from virtualization.models import VMInterface
from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
RIR_UTILIZATION = """
@@ -67,11 +68,7 @@ IPADDRESS_LINK = """
"""
IPADDRESS_ASSIGN_LINK = """
{% if request.GET %}
<a href="{% url 'ipam:ipaddress_edit' pk=record.pk %}?interface={{ request.GET.interface }}&return_url={{ request.GET.return_url }}">{{ record }}</a>
{% else %}
<a href="{% url 'ipam:ipaddress_edit' pk=record.pk %}?interface={{ record.interface.pk }}&return_url={{ request.path }}">{{ record }}</a>
{% endif %}
<a href="{% url 'ipam:ipaddress_edit' pk=record.pk %}?{% if request.GET.interface %}interface={{ request.GET.interface }}{% elif request.GET.vminterface %}vminterface={{ request.GET.vminterface }}{% endif %}&return_url={{ request.GET.return_url }}">{{ record }}</a>
"""
VRF_LINK = """
@@ -103,7 +100,7 @@ VLAN_LINK = """
"""
VLAN_PREFIXES = """
{% for prefix in record.prefixes.unrestricted %}
{% for prefix in record.prefixes.all %}
<a href="{% url 'ipam:prefix' pk=prefix.pk %}">{{ prefix }}</a>{% if not forloop.last %}<br />{% endif %}
{% empty %}
&mdash;
@@ -128,9 +125,11 @@ VLANGROUP_ADD_VLAN = """
{% endwith %}
"""
VLAN_MEMBER_UNTAGGED = """
VLAN_MEMBER_TAGGED = """
{% if record.untagged_vlan_id == vlan.pk %}
<i class="glyphicon glyphicon-ok">
<span class="text-danger"><i class="fa fa-close"></i></span>
{% else %}
<span class="text-success"><i class="fa fa-check"></i></span>
{% endif %}
"""
@@ -387,15 +386,23 @@ class IPAddressTable(BaseTable):
tenant = tables.TemplateColumn(
template_code=TENANT_LINK
)
assigned = tables.BooleanColumn(
accessor='assigned_object_id',
verbose_name='Assigned'
assigned_object = tables.Column(
linkify=True,
orderable=False,
verbose_name='Interface'
)
assigned_object_parent = tables.Column(
accessor='assigned_object__parent',
linkify=True,
orderable=False,
verbose_name='Interface Parent'
)
class Meta(BaseTable.Meta):
model = IPAddress
fields = (
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned', 'dns_name', 'description',
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned_object', 'assigned_object_parent', 'dns_name',
'description',
)
row_attrs = {
'class': lambda record: 'success' if not isinstance(record, IPAddress) else '',
@@ -411,6 +418,10 @@ class IPAddressDetailTable(IPAddressTable):
tenant = tables.TemplateColumn(
template_code=COL_TENANT
)
assigned = BooleanColumn(
accessor='assigned_object_id',
verbose_name='Assigned'
)
tags = TagColumn(
url_name='ipam:ipaddress_list'
)
@@ -545,15 +556,15 @@ class VLANDetailTable(VLANTable):
default_columns = ('pk', 'vid', 'site', 'group', 'name', 'prefixes', 'tenant', 'status', 'role', 'description')
class VLANMemberTable(BaseTable):
parent = tables.LinkColumn(
order_by=['device', 'virtual_machine']
)
class VLANMembersTable(BaseTable):
"""
Base table for Interface and VMInterface assignments
"""
name = tables.LinkColumn(
verbose_name='Interface'
)
untagged = tables.TemplateColumn(
template_code=VLAN_MEMBER_UNTAGGED,
tagged = tables.TemplateColumn(
template_code=VLAN_MEMBER_TAGGED,
orderable=False
)
actions = tables.TemplateColumn(
@@ -562,9 +573,21 @@ class VLANMemberTable(BaseTable):
verbose_name=''
)
class VLANDevicesTable(VLANMembersTable):
device = tables.LinkColumn()
class Meta(BaseTable.Meta):
model = Interface
fields = ('parent', 'name', 'untagged', 'actions')
fields = ('device', 'name', 'tagged', 'actions')
class VLANVirtualMachinesTable(VLANMembersTable):
virtual_machine = tables.LinkColumn()
class Meta(BaseTable.Meta):
model = VMInterface
fields = ('virtual_machine', 'name', 'tagged', 'actions')
class InterfaceVLANTable(BaseTable):

View File

@@ -90,7 +90,8 @@ urlpatterns = [
path('vlans/edit/', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'),
path('vlans/delete/', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'),
path('vlans/<int:pk>/', views.VLANView.as_view(), name='vlan'),
path('vlans/<int:pk>/members/', views.VLANMembersView.as_view(), name='vlan_members'),
path('vlans/<int:pk>/interfaces/', views.VLANInterfacesView.as_view(), name='vlan_interfaces'),
path('vlans/<int:pk>/vm-interfaces/', views.VLANVMInterfacesView.as_view(), name='vlan_vminterfaces'),
path('vlans/<int:pk>/edit/', views.VLANEditView.as_view(), name='vlan_edit'),
path('vlans/<int:pk>/delete/', views.VLANDeleteView.as_view(), name='vlan_delete'),
path('vlans/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}),

View File

@@ -1,12 +1,11 @@
import netaddr
from django.conf import settings
from django.db.models import Count, Prefetch
from django.db.models import Prefetch
from django.db.models.expressions import RawSQL
from django.shortcuts import get_object_or_404, redirect, render
from django_tables2 import RequestConfig
from dcim.models import Device, Interface
from utilities.paginator import EnhancedPaginator
from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.utils import get_subquery
from utilities.views import (
BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView,
@@ -79,7 +78,9 @@ class VRFBulkDeleteView(BulkDeleteView):
#
class RIRListView(ObjectListView):
queryset = RIR.objects.annotate(aggregate_count=Count('aggregates')).order_by(*RIR._meta.ordering)
queryset = RIR.objects.annotate(
aggregate_count=get_subquery(Aggregate, 'rir')
)
filterset = filters.RIRFilterSet
filterset_form = forms.RIRFilterForm
table = tables.RIRDetailTable
@@ -172,7 +173,9 @@ class RIRBulkImportView(BulkImportView):
class RIRBulkDeleteView(BulkDeleteView):
queryset = RIR.objects.annotate(aggregate_count=Count('aggregates')).order_by(*RIR._meta.ordering)
queryset = RIR.objects.annotate(
aggregate_count=get_subquery(Aggregate, 'rir')
)
filterset = filters.RIRFilterSet
table = tables.RIRTable
@@ -184,7 +187,7 @@ class RIRBulkDeleteView(BulkDeleteView):
class AggregateListView(ObjectListView):
queryset = Aggregate.objects.prefetch_related('rir').annotate(
child_count=RawSQL('SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix', ())
).order_by(*Aggregate._meta.ordering)
)
filterset = filters.AggregateFilterSet
filterset_form = forms.AggregateFilterForm
table = tables.AggregateDetailTable
@@ -233,7 +236,7 @@ class AggregateView(ObjectView):
paginate = {
'paginator_class': EnhancedPaginator,
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
'per_page': get_paginate_count(request)
}
RequestConfig(request, paginate).configure(prefix_table)
@@ -391,7 +394,7 @@ class PrefixPrefixesView(ObjectView):
paginate = {
'paginator_class': EnhancedPaginator,
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
'per_page': get_paginate_count(request)
}
RequestConfig(request, paginate).configure(prefix_table)
@@ -435,7 +438,7 @@ class PrefixIPAddressesView(ObjectView):
paginate = {
'paginator_class': EnhancedPaginator,
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
'per_page': get_paginate_count(request)
}
RequestConfig(request, paginate).configure(ip_table)
@@ -493,7 +496,7 @@ class PrefixBulkDeleteView(BulkDeleteView):
class IPAddressListView(ObjectListView):
queryset = IPAddress.objects.prefetch_related(
'vrf__tenant', 'tenant', 'nat_inside'
'vrf__tenant', 'tenant', 'nat_inside', 'assigned_object'
)
filterset = filters.IPAddressFilterSet
filterset_form = forms.IPAddressFilterForm
@@ -527,7 +530,8 @@ class IPAddressView(ObjectView):
# Exclude anycast IPs if this IP is anycast
if ipaddress.role == IPAddressRoleChoices.ROLE_ANYCAST:
duplicate_ips = duplicate_ips.exclude(role=IPAddressRoleChoices.ROLE_ANYCAST)
duplicate_ips_table = tables.IPAddressTable(list(duplicate_ips), orderable=False)
# Limit to a maximum of 10 duplicates displayed here
duplicate_ips_table = tables.IPAddressTable(duplicate_ips[:10], orderable=False)
# Related IP table
related_ips = IPAddress.objects.restrict(request.user, 'view').exclude(
@@ -539,7 +543,7 @@ class IPAddressView(ObjectView):
paginate = {
'paginator_class': EnhancedPaginator,
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
'per_page': get_paginate_count(request)
}
RequestConfig(request, paginate).configure(related_ips_table)
@@ -547,6 +551,7 @@ class IPAddressView(ObjectView):
'ipaddress': ipaddress,
'parent_prefixes_table': parent_prefixes_table,
'duplicate_ips_table': duplicate_ips_table,
'more_duplicate_ips': duplicate_ips.count() > 10,
'related_ips_table': related_ips_table,
})
@@ -582,7 +587,7 @@ class IPAddressAssignView(ObjectView):
def dispatch(self, request, *args, **kwargs):
# Redirect user if an interface has not been provided
if 'interface' not in request.GET:
if 'interface' not in request.GET and 'vminterface' not in request.GET:
return redirect('ipam:ipaddress_add')
return super().dispatch(request, *args, **kwargs)
@@ -609,7 +614,7 @@ class IPAddressAssignView(ObjectView):
return render(request, 'ipam/ipaddress_assign.html', {
'form': form,
'table': table,
'return_url': request.GET.get('return_url', ''),
'return_url': request.GET.get('return_url'),
})
@@ -650,8 +655,8 @@ class IPAddressBulkDeleteView(BulkDeleteView):
class VLANGroupListView(ObjectListView):
queryset = VLANGroup.objects.prefetch_related('site').annotate(
vlan_count=Count('vlans')
).order_by(*VLANGroup._meta.ordering)
vlan_count=get_subquery(VLAN, 'group')
)
filterset = filters.VLANGroupFilterSet
filterset_form = forms.VLANGroupFilterForm
table = tables.VLANGroupTable
@@ -674,8 +679,8 @@ class VLANGroupBulkImportView(BulkImportView):
class VLANGroupBulkDeleteView(BulkDeleteView):
queryset = VLANGroup.objects.prefetch_related('site').annotate(
vlan_count=Count('vlans')
).order_by(*VLANGroup._meta.ordering)
vlan_count=get_subquery(VLAN, 'group')
)
filterset = filters.VLANGroupFilterSet
table = tables.VLANGroupTable
@@ -699,7 +704,7 @@ class VLANGroupVLANsView(ObjectView):
paginate = {
'paginator_class': EnhancedPaginator,
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
'per_page': get_paginate_count(request),
}
RequestConfig(request, paginate).configure(vlan_table)
@@ -713,6 +718,7 @@ class VLANGroupVLANsView(ObjectView):
return render(request, 'ipam/vlangroup_vlans.html', {
'vlan_group': vlan_group,
'first_available_vlan': vlan_group.get_next_available_vid(),
'bulk_querystring': 'group_id={}'.format(vlan_group.pk),
'vlan_table': vlan_table,
'permissions': permissions,
})
@@ -749,26 +755,45 @@ class VLANView(ObjectView):
})
class VLANMembersView(ObjectView):
class VLANInterfacesView(ObjectView):
queryset = VLAN.objects.all()
def get(self, request, pk):
vlan = get_object_or_404(self.queryset, pk=pk)
members = vlan.get_members().restrict(request.user, 'view').prefetch_related('device', 'virtual_machine')
members_table = tables.VLANMemberTable(members)
interfaces = vlan.get_interfaces().prefetch_related('device')
members_table = tables.VLANDevicesTable(interfaces)
paginate = {
'paginator_class': EnhancedPaginator,
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
'per_page': get_paginate_count(request)
}
RequestConfig(request, paginate).configure(members_table)
return render(request, 'ipam/vlan_members.html', {
return render(request, 'ipam/vlan_interfaces.html', {
'vlan': vlan,
'members_table': members_table,
'active_tab': 'members',
'active_tab': 'interfaces',
})
class VLANVMInterfacesView(ObjectView):
queryset = VLAN.objects.all()
def get(self, request, pk):
vlan = get_object_or_404(self.queryset, pk=pk)
interfaces = vlan.get_vminterfaces().prefetch_related('virtual_machine')
members_table = tables.VLANVirtualMachinesTable(interfaces)
paginate = {
'paginator_class': EnhancedPaginator,
'per_page': get_paginate_count(request)
}
RequestConfig(request, paginate).configure(members_table)
return render(request, 'ipam/vlan_vminterfaces.html', {
'vlan': vlan,
'members_table': members_table,
'active_tab': 'vminterfaces',
})

View File

@@ -137,19 +137,24 @@ class LDAPBackend:
def __new__(cls, *args, **kwargs):
try:
import ldap
from django_auth_ldap.backend import LDAPBackend as LDAPBackend_, LDAPSettings
except ImportError:
raise ImproperlyConfigured(
"LDAP authentication has been configured, but django-auth-ldap is not installed."
)
import ldap
except ModuleNotFoundError as e:
if getattr(e, 'name') == 'django_auth_ldap':
raise ImproperlyConfigured(
"LDAP authentication has been configured, but django-auth-ldap is not installed."
)
raise e
try:
from netbox import ldap_config
except ImportError:
raise ImproperlyConfigured(
"ldap_config.py does not exist"
)
except ModuleNotFoundError as e:
if getattr(e, 'name') == 'ldap_config':
raise ImproperlyConfigured(
"LDAP configuration file not found: Check that ldap_config.py has been created alongside "
"configuration.py."
)
raise e
try:
getattr(ldap_config, 'AUTH_LDAP_SERVER_URI')
@@ -172,9 +177,4 @@ class LDAPBackend:
if getattr(ldap_config, 'LDAP_IGNORE_CERT_ERRORS', False):
ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
# Enable logging for django_auth_ldap
ldap_logger = logging.getLogger('django_auth_ldap')
ldap_logger.addHandler(logging.StreamHandler())
ldap_logger.setLevel(logging.DEBUG)
return obj

View File

@@ -33,7 +33,6 @@ REDIS = {
# 'SENTINEL_SERVICE': 'netbox',
'PASSWORD': '',
'DATABASE': 0,
'DEFAULT_TIMEOUT': 300,
'SSL': False,
},
'caching': {
@@ -44,7 +43,6 @@ REDIS = {
# 'SENTINEL_SERVICE': 'netbox',
'PASSWORD': '',
'DATABASE': 1,
'DEFAULT_TIMEOUT': 300,
'SSL': False,
}
}
@@ -232,6 +230,9 @@ RELEASE_CHECK_URL = None
# this setting is derived from the installed location.
# REPORTS_ROOT = '/opt/netbox/netbox/reports'
# Maximum execution time for background tasks, in seconds.
RQ_DEFAULT_TIMEOUT = 300
# The file path where custom scripts will be stored. A trailing slash is not needed. Note that the default value of
# this setting is derived from the installed location.
# SCRIPTS_ROOT = '/opt/netbox/netbox/scripts'

View File

@@ -7,8 +7,8 @@ ALLOWED_HOSTS = ['*']
DATABASE = {
'NAME': 'netbox',
'USER': '',
'PASSWORD': '',
'USER': 'netbox',
'PASSWORD': 'netbox',
'HOST': 'localhost',
'PORT': '',
'CONN_MAX_AGE': 300,
@@ -24,7 +24,6 @@ REDIS = {
'PORT': 6379,
'PASSWORD': '',
'DATABASE': 0,
'DEFAULT_TIMEOUT': 300,
'SSL': False,
},
'caching': {
@@ -32,7 +31,6 @@ REDIS = {
'PORT': 6379,
'PASSWORD': '',
'DATABASE': 1,
'DEFAULT_TIMEOUT': 300,
'SSL': False,
}
}

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