Compare commits

...

241 Commits

Author SHA1 Message Date
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
160 changed files with 5112 additions and 3329 deletions

View File

@@ -11,7 +11,7 @@ about: Report a reproducible bug in the current release of NetBox
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
https://groups.google.com/g/netbox-discuss
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

@@ -11,7 +11,7 @@ about: Propose a new NetBox feature or enhancement
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
https://groups.google.com/g/netbox-discuss
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 +21,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

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

@@ -8,7 +8,7 @@ except to report bugs or request features.
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).
installation. You can find us [here](https://groups.google.com/g/netbox-discuss).
### Slack
@@ -164,7 +164,7 @@ overlooked.
* Official channels for communication include:
* GitHub issues/pull requests
* The [netbox-discuss](https://groups.google.com/forum/#!forum/netbox-discuss) mailing list
* 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,7 +12,7 @@ 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),
Questions? Comments? Please subscribe to [the netbox-discuss mailing list](https://groups.google.com/g/netbox-discuss),
or join us in the #netbox Slack channel on [NetworkToCode](https://networktocode.slack.com/)!
### Build Status
@@ -44,7 +44,7 @@ and run `upgrade.sh`.
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).
For general discussion, please consider joining our [mailing list](https://groups.google.com/g/netbox-discuss).
If you are interested in contributing to the development of NetBox, please read
our [contributing guide](CONTRIBUTING.md) prior to beginning any work.

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

@@ -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

@@ -7,7 +7,7 @@ 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.
* [The mailing list](https://groups.google.com/g/netbox-discuss) - The preferred forum for general discussion and support issues. Ideal for shaping a feature request prior to submitting an issue.
* [#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

View File

@@ -89,7 +89,3 @@ On the `develop` branch, update `VERSION` in `settings.py` to point to the next
```
VERSION = 'v2.3.5-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

@@ -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

@@ -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,179 @@
# 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.9 (2020-11-09)
### 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
* [#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
* [#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
* [#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
### 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
---
## v2.9-beta1 (2020-07-23)
## v2.9.8 (2020-10-30)
**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
* [#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 +185,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 +218,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 +249,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 +280,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

@@ -76,6 +76,7 @@ nav:
- 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

@@ -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.db.models import Count
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.views import (
BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
)
@@ -45,7 +44,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)

View File

@@ -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 (
@@ -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:

View File

@@ -814,6 +814,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'
@@ -833,6 +836,9 @@ class PortTypeChoices(ChoiceSet):
'Copper',
(
(TYPE_8P8C, '8P8C'),
(TYPE_8P6C, '8P6C'),
(TYPE_8P4C, '8P4C'),
(TYPE_8P2C, '8P2C'),
(TYPE_110_PUNCH, '110 Punch'),
(TYPE_BNC, 'BNC'),
(TYPE_MRJ21, 'MRJ21'),

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,14 +457,17 @@ class RackRoleCSVForm(CSVModelForm):
#
class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
site = DynamicModelChoiceField(
queryset=Site.objects.all()
)
group = DynamicModelChoiceField(
queryset=RackGroup.objects.all(),
region = DynamicModelChoiceField(
queryset=Region.objects.all(),
required=False,
initial_params={
'sites': '$site'
}
)
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
query_params={
'site_id': '$site'
'region_id': '$region'
}
)
role = DynamicModelChoiceField(
@@ -470,8 +483,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 +562,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 +715,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 +741,7 @@ class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm):
display_field='display_name',
query_params={
'site_id': '$site',
'group_id': 'rack',
'group_id': '$rack',
}
)
units = NumericArrayField(
@@ -809,15 +843,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'),
@@ -1672,7 +1714,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 +1725,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 +1759,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 +1784,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 +1826,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 +1853,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 +2359,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 +2432,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 +2521,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 +2724,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 +2799,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 +2877,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 +2939,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 +3459,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(),
@@ -3420,8 +3496,8 @@ class ConnectCableToDeviceForm(BootstrapMixin, forms.ModelForm):
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',
]
widgets = {
'status': StaticSelect2,
@@ -3518,10 +3594,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(),
@@ -3545,8 +3629,8 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, forms.ModelForm):
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',
]
def clean_termination_b_id(self):
@@ -3555,11 +3639,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(),
@@ -3801,10 +3892,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 +3952,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 +3976,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 +4000,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 +4035,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 +4061,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 +4080,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 +4177,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 +4288,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 +4316,7 @@ class PowerPanelForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = PowerPanel
fields = [
'site', 'rack_group', 'name', 'tags',
'region', 'site', 'rack_group', 'name', 'tags',
]
@@ -4197,9 +4351,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 +4415,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 +4455,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 +4465,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):
"""
@@ -688,27 +697,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]:
@@ -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

@@ -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,6 +1,5 @@
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
@@ -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', {
@@ -310,8 +313,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 +338,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 +417,6 @@ class RackReservationListView(ObjectListView):
filterset = filters.RackReservationFilterSet
filterset_form = forms.RackReservationFilterForm
table = tables.RackReservationTable
action_buttons = ('export',)
class RackReservationView(ObjectView):
@@ -1033,7 +1041,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 +1163,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 +1241,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 +1301,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 +1361,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 +1421,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 +1573,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 +1633,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 +1693,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 +2043,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)

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

@@ -26,6 +26,29 @@ from utilities.utils import copy_safe_request
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
@@ -140,6 +163,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

@@ -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

@@ -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,8 @@
from collections import OrderedDict
from django.db.models import Q, QuerySet
from django.db.models import OuterRef, Subquery, Q
from utilities.query_functions import EmptyGroupByJSONBAgg, OrderableJSONBAgg
from utilities.querysets import RestrictedQuerySet
@@ -23,9 +24,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 +49,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 +60,73 @@ 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
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=OuterRef('tags')) | 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,311 @@ 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())

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,5 +1,4 @@
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
@@ -13,7 +12,7 @@ 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.paginator import EnhancedPaginator, get_paginate_count
from utilities.utils import copy_safe_request, shallow_compare_dict
from utilities.views import (
BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
@@ -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

@@ -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

@@ -88,7 +88,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 +247,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

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,18 @@ 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
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 +669,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 +683,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 +953,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 +1012,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 +1114,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

@@ -6,7 +6,7 @@ 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,
@@ -233,7 +233,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 +391,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 +435,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 +493,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 +527,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 +540,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 +548,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 +584,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 +611,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'),
})
@@ -699,7 +701,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 +715,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 +752,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

@@ -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,
}
}

View File

@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
# Environment setup
#
VERSION = '2.9-beta2'
VERSION = '2.9.9'
# Hostname
HOSTNAME = platform.node()
@@ -38,10 +38,12 @@ if platform.python_version_tuple() < ('3', '6'):
# Import configuration parameters
try:
from netbox import configuration
except ImportError:
raise ImproperlyConfigured(
"Configuration file is not present. Please define netbox/netbox/configuration.py per the documentation."
)
except ModuleNotFoundError as e:
if getattr(e, 'name') == 'configuration':
raise ImproperlyConfigured(
"Configuration file is not present. Please define netbox/netbox/configuration.py per the documentation."
)
raise
# Enforce required configuration parameters
for parameter in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY', 'REDIS']:
@@ -110,6 +112,7 @@ REMOTE_AUTH_HEADER = getattr(configuration, 'REMOTE_AUTH_HEADER', 'HTTP_REMOTE_U
RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None)
RELEASE_CHECK_TIMEOUT = getattr(configuration, 'RELEASE_CHECK_TIMEOUT', 24 * 3600)
REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None)
SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d')
@@ -142,6 +145,13 @@ if type(REMOTE_AUTH_DEFAULT_PERMISSIONS) is not dict:
)
except TypeError:
raise ImproperlyConfigured("REMOTE_AUTH_DEFAULT_PERMISSIONS must be a dictionary.")
# Backward compatibility for REMOTE_AUTH_BACKEND
if REMOTE_AUTH_BACKEND == 'utilities.auth_backends.RemoteUserBackend':
warnings.warn(
"RemoteUserBackend has moved! Please update your configuration to:\n"
" REMOTE_AUTH_BACKEND='netbox.authentication.RemoteUserBackend'"
)
REMOTE_AUTH_BACKEND = 'netbox.authentication.RemoteUserBackend'
#
@@ -175,11 +185,13 @@ if STORAGE_BACKEND is not None:
try:
import storages.utils
except ImportError:
raise ImproperlyConfigured(
"STORAGE_BACKEND is set to {} but django-storages is not present. It can be installed by running 'pip "
"install django-storages'.".format(STORAGE_BACKEND)
)
except ModuleNotFoundError as e:
if getattr(e, 'name') == 'storages':
raise ImproperlyConfigured(
f"STORAGE_BACKEND is set to {STORAGE_BACKEND} but django-storages is not present. It can be "
f"installed by running 'pip install django-storages'."
)
raise e
# Monkey-patch django-storages to fetch settings from STORAGE_CONFIG
def _setting(name, default=None):
@@ -213,10 +225,13 @@ TASKS_REDIS_USING_SENTINEL = all([
len(TASKS_REDIS_SENTINELS) > 0
])
TASKS_REDIS_SENTINEL_SERVICE = TASKS_REDIS.get('SENTINEL_SERVICE', 'default')
TASKS_REDIS_SENTINEL_TIMEOUT = TASKS_REDIS.get('SENTINEL_TIMEOUT', 10)
TASKS_REDIS_PASSWORD = TASKS_REDIS.get('PASSWORD', '')
TASKS_REDIS_DATABASE = TASKS_REDIS.get('DATABASE', 0)
TASKS_REDIS_DEFAULT_TIMEOUT = TASKS_REDIS.get('DEFAULT_TIMEOUT', 300)
TASKS_REDIS_SSL = TASKS_REDIS.get('SSL', False)
# TODO: Remove in v2.10 (see #5171)
if 'DEFAULT_TIMEOUT' in TASKS_REDIS:
warnings.warn('DEFAULT_TIMEOUT is no longer supported under REDIS configuration. Set RQ_DEFAULT_TIMEOUT instead.')
# Caching
if 'caching' not in REDIS:
@@ -234,7 +249,6 @@ CACHING_REDIS_USING_SENTINEL = all([
CACHING_REDIS_SENTINEL_SERVICE = CACHING_REDIS.get('SENTINEL_SERVICE', 'default')
CACHING_REDIS_PASSWORD = CACHING_REDIS.get('PASSWORD', '')
CACHING_REDIS_DATABASE = CACHING_REDIS.get('DATABASE', 0)
CACHING_REDIS_DEFAULT_TIMEOUT = CACHING_REDIS.get('DEFAULT_TIMEOUT', 300)
CACHING_REDIS_SSL = CACHING_REDIS.get('SSL', False)
@@ -542,7 +556,7 @@ if TASKS_REDIS_USING_SENTINEL:
'PASSWORD': TASKS_REDIS_PASSWORD,
'SOCKET_TIMEOUT': None,
'CONNECTION_KWARGS': {
'socket_connect_timeout': TASKS_REDIS_DEFAULT_TIMEOUT
'socket_connect_timeout': TASKS_REDIS_SENTINEL_TIMEOUT
},
}
else:
@@ -551,8 +565,8 @@ else:
'PORT': TASKS_REDIS_PORT,
'DB': TASKS_REDIS_DATABASE,
'PASSWORD': TASKS_REDIS_PASSWORD,
'DEFAULT_TIMEOUT': TASKS_REDIS_DEFAULT_TIMEOUT,
'SSL': TASKS_REDIS_SSL,
'DEFAULT_TIMEOUT': RQ_DEFAULT_TIMEOUT,
}
RQ_QUEUES = {
@@ -586,11 +600,13 @@ for plugin_name in PLUGINS:
# Import plugin module
try:
plugin = importlib.import_module(plugin_name)
except ImportError:
raise ImproperlyConfigured(
"Unable to import plugin {}: Module not found. Check that the plugin module has been installed within the "
"correct Python environment.".format(plugin_name)
)
except ModuleNotFoundError as e:
if getattr(e, 'name') == plugin_name:
raise ImproperlyConfigured(
"Unable to import plugin {}: Module not found. Check that the plugin module has been installed within the "
"correct Python environment.".format(plugin_name)
)
raise e
# Determine plugin config and add to INSTALLED_APPS.
try:

View File

@@ -37,6 +37,7 @@ from secrets.tables import SecretTable
from tenancy.filters import TenantFilterSet
from tenancy.models import Tenant
from tenancy.tables import TenantTable
from utilities.utils import get_subquery
from virtualization.filters import ClusterFilterSet, VirtualMachineFilterSet
from virtualization.models import Cluster, VirtualMachine
from virtualization.tables import ClusterTable, VirtualMachineDetailTable
@@ -120,7 +121,10 @@ SEARCH_TYPES = OrderedDict((
}),
# Virtualization
('cluster', {
'queryset': Cluster.objects.prefetch_related('type', 'group'),
'queryset': Cluster.objects.prefetch_related('type', 'group').annotate(
device_count=get_subquery(Device, 'cluster'),
vm_count=get_subquery(VirtualMachine, 'cluster')
),
'filterset': ClusterFilterSet,
'table': ClusterTable,
'url': 'virtualization:cluster_list',

View File

@@ -31,9 +31,12 @@
The complete exception is provided below:
</p>
<pre><strong>{{ exception }}</strong><br />
{{ error }}</pre>
{{ error }}
Python version: {{ python_version }}
NetBox version: {{ netbox_version }}</pre>
<p>
If further assistance is required, please post to the <a href="https://groups.google.com/forum/#!forum/netbox-discuss">NetBox mailing list</a>.
If further assistance is required, please post to the <a href="https://groups.google.com/g/netbox-discuss">NetBox mailing list</a>.
</p>
<div class="text-right">
<a href="{% url 'home' %}" class="btn btn-primary">Home Page</a>

View File

@@ -40,6 +40,7 @@
<p class="form-control-static">{{ form.term_side.value }}</p>
</div>
</div>
{% render_field form.region %}
{% render_field form.site %}
</div>
</div>

View File

@@ -32,6 +32,12 @@
<div class="panel-body">
{% if termination_a.device %}
{# Device component #}
<div class="form-group">
<label class="col-md-3 control-label required">Region</label>
<div class="col-md-9">
<p class="form-control-static">{{ termination_a.device.site.region }}</p>
</div>
</div>
<div class="form-group">
<label class="col-md-3 control-label required">Site</label>
<div class="col-md-9">
@@ -111,6 +117,9 @@
{% if 'termination_b_provider' in form.fields %}
{% render_field form.termination_b_provider %}
{% endif %}
{% if 'termination_b_region' in form.fields %}
{% render_field form.termination_b_region %}
{% endif %}
{% if 'termination_b_site' in form.fields %}
{% render_field form.termination_b_site %}
{% endif %}

View File

@@ -11,11 +11,8 @@
<div class="row noprint">
<div class="col-sm-8 col-md-9">
<ol class="breadcrumb">
<li><a href="{% url 'dcim:site' slug=device.site.slug %}">{{ device.site }}</a></li>
{% if device.rack %}
<li><a href="{% url 'dcim:rack_list' %}?site={{ device.site.slug }}">Racks</a></li>
<li><a href="{% url 'dcim:rack' pk=device.rack.pk %}">{{ device.rack }}</a></li>
{% endif %}
<li><a href="{% url 'dcim:device_list' %}">Devices</a></li>
<li><a href="{% url 'dcim:device_list' %}?site={{ device.site.slug }}">{{ device.site }}</a></li>
{% if device.parent_bay %}
<li><a href="{% url 'dcim:device' pk=device.parent_bay.device.pk %}">{{ device.parent_bay.device }}</a></li>
<li>{{ device.parent_bay }}</li>
@@ -51,28 +48,28 @@
</button>
<ul class="dropdown-menu">
{% if perms.dcim.add_consoleport %}
<li><a href="{% url 'dcim:consoleport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}">Console Ports</a></li>
<li><a href="{% url 'dcim:consoleport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}%23tab_consoleports">Console Ports</a></li>
{% endif %}
{% if perms.dcim.add_consoleserverport %}
<li><a href="{% url 'dcim:consoleserverport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}">Console Server Ports</a></li>
<li><a href="{% url 'dcim:consoleserverport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}%23tab_consoleserverports">Console Server Ports</a></li>
{% endif %}
{% if perms.dcim.add_powerport %}
<li><a href="{% url 'dcim:powerport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}">Power Ports</a></li>
<li><a href="{% url 'dcim:powerport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}%23tab_powerports">Power Ports</a></li>
{% endif %}
{% if perms.dcim.add_poweroutlet %}
<li><a href="{% url 'dcim:poweroutlet_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}">Power Outlets</a></li>
<li><a href="{% url 'dcim:poweroutlet_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}%23tab_poweroutlets">Power Outlets</a></li>
{% endif %}
{% if perms.dcim.add_interface %}
<li><a href="{% url 'dcim:interface_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}">Interfaces</a></li>
<li><a href="{% url 'dcim:interface_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}%23tab_interfaces">Interfaces</a></li>
{% endif %}
{% if perms.dcim.add_frontport %}
<li><a href="{% url 'dcim:frontport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}">Front Ports</a></li>
<li><a href="{% url 'dcim:frontport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}%23tab_frontports">Front Ports</a></li>
{% endif %}
{% if perms.dcim.add_rearport %}
<li><a href="{% url 'dcim:rearport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}">Rear Ports</a></li>
<li><a href="{% url 'dcim:rearport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}%23tab_rearports">Rear Ports</a></li>
{% endif %}
{% if perms.dcim.add_devicebay %}
<li><a href="{% url 'dcim:devicebay_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}">Device Bays</a></li>
<li><a href="{% url 'dcim:devicebay_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}%23tab_devicebays">Device Bays</a></li>
{% endif %}
{% if perms.dcim.add_inventoryitem %}
<li><a href="{% url 'dcim:inventoryitem_add' %}?device={{ device.pk }}&return_url={% url 'dcim:device_inventory' pk=device.pk %}">Inventory Items</a></li>
@@ -101,7 +98,7 @@
</li>
<li role="presentation"{% if active_tab == 'inventory' %} class="active"{% endif %}>
<a href="{% url 'dcim:device_inventory' pk=device.pk %}">
Inventory <span class="badge">{{ device.inventoryitems.unrestricted.count }}</span>
Inventory <span class="badge">{{ device.inventoryitems.count }}</span>
</a>
</li>
{% if perms.dcim.napalm_read_device %}
@@ -151,8 +148,10 @@
<td>
{% if device.rack %}
{% if device.rack.group %}
<a href="{{ device.rack.group.get_absolute_url }}">{{ device.rack.group }}</a>
<i class="fa fa-angle-right"></i>
{% for group in device.rack.group.get_ancestors %}
<a href="{{ group.get_absolute_url }}">{{ group }}</a> <i class="fa fa-caret-right"></i>
{% endfor %}
<a href="{{ device.rack.group.get_absolute_url }}">{{ device.rack.group }}</a> <i class="fa fa-caret-right"></i>
{% endif %}
<a href="{% url 'dcim:rack' pk=device.rack.pk %}">{{ device.rack }}</a>
{% else %}
@@ -327,7 +326,7 @@
{% plugin_left_page device %}
</div>
<div class="col-md-6">
{% if power_ports and poweroutlets %}
{% if powerports and poweroutlets %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>Power Utilization</strong>
@@ -340,7 +339,7 @@
<th>Available</th>
<th>Utilization</th>
</tr>
{% for pp in power_ports %}
{% for pp in powerports %}
{% with utilization=pp.get_power_draw powerfeed=pp.connected_endpoint %}
<tr>
<td>{{ pp }}</td>
@@ -538,26 +537,26 @@
</table>
<div class="panel-footer noprint">
{% if interfaces and perms.dcim.change_interface %}
<button type="submit" name="_rename" formaction="{% url 'dcim:interface_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<button type="submit" name="_rename" formaction="{% url 'dcim:interface_bulk_rename' %}?return_url={{ device.get_absolute_url }}%23tab_interfaces" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button>
<button type="submit" name="_edit" formaction="{% url 'dcim:interface_bulk_edit' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<button type="submit" name="_edit" formaction="{% url 'dcim:interface_bulk_edit' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}%23tab_interfaces" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
</button>
{% endif %}
{% if interfaces and perms.dcim.change_interface %}
<button type="submit" name="_disconnect" formaction="{% url 'dcim:interface_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<button type="submit" name="_disconnect" formaction="{% url 'dcim:interface_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}%23tab_interfaces" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</button>
{% endif %}
{% if interfaces and perms.dcim.delete_interface %}
<button type="submit" name="_delete" formaction="{% url 'dcim:interface_bulk_delete' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<button type="submit" name="_delete" formaction="{% url 'dcim:interface_bulk_delete' %}?return_url={{ device.get_absolute_url }}%23tab_interfaces" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button>
{% endif %}
{% if perms.dcim.add_interface %}
<div class="pull-right">
<a href="{% url 'dcim:interface_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-primary btn-xs">
<a href="{% url 'dcim:interface_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}%23tab_interfaces" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
</a>
</div>
@@ -598,24 +597,24 @@
</table>
<div class="panel-footer noprint">
{% if frontports and perms.dcim.change_frontport %}
<button type="submit" name="_rename" formaction="{% url 'dcim:frontport_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<button type="submit" name="_rename" formaction="{% url 'dcim:frontport_bulk_rename' %}?return_url={{ device.get_absolute_url }}%23tab_frontports" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button>
<button type="submit" name="_edit" formaction="{% url 'dcim:frontport_bulk_edit' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<button type="submit" name="_edit" formaction="{% url 'dcim:frontport_bulk_edit' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}%23tab_frontports" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
</button>
<button type="submit" name="_disconnect" formaction="{% url 'dcim:frontport_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<button type="submit" name="_disconnect" formaction="{% url 'dcim:frontport_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}%23tab_frontports" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</button>
{% endif %}
{% if frontports and perms.dcim.delete_frontport %}
<button type="submit" formaction="{% url 'dcim:frontport_bulk_delete' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<button type="submit" formaction="{% url 'dcim:frontport_bulk_delete' %}?return_url={{ device.get_absolute_url }}%23tab_frontports" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button>
{% endif %}
{% if perms.dcim.add_frontport %}
<div class="pull-right">
<a href="{% url 'dcim:frontport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-primary btn-xs">
<a href="{% url 'dcim:frontport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}%23tab_frontports" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add front ports
</a>
</div>
@@ -655,24 +654,24 @@
</table>
<div class="panel-footer noprint">
{% if rearports and perms.dcim.change_rearport %}
<button type="submit" name="_rename" formaction="{% url 'dcim:rearport_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<button type="submit" name="_rename" formaction="{% url 'dcim:rearport_bulk_rename' %}?return_url={{ device.get_absolute_url }}%23tab_rearports" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button>
<button type="submit" name="_edit" formaction="{% url 'dcim:rearport_bulk_edit' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<button type="submit" name="_edit" formaction="{% url 'dcim:rearport_bulk_edit' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}%23tab_rearports" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
</button>
<button type="submit" name="_disconnect" formaction="{% url 'dcim:rearport_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<button type="submit" name="_disconnect" formaction="{% url 'dcim:rearport_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}%23tab_rearports" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</button>
{% endif %}
{% if rearports and perms.dcim.delete_rearport %}
<button type="submit" formaction="{% url 'dcim:rearport_bulk_delete' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<button type="submit" formaction="{% url 'dcim:rearport_bulk_delete' %}?return_url={{ device.get_absolute_url }}%23tab_rearports" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button>
{% endif %}
{% if perms.dcim.add_rearport %}
<div class="pull-right">
<a href="{% url 'dcim:rearport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-primary btn-xs">
<a href="{% url 'dcim:rearport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}%23tab_rearports" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add rear ports
</a>
</div>
@@ -709,24 +708,24 @@
</table>
<div class="panel-footer noprint">
{% if consoleports and perms.dcim.change_consoleport %}
<button type="submit" name="_rename" formaction="{% url 'dcim:consoleport_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<button type="submit" name="_rename" formaction="{% url 'dcim:consoleport_bulk_rename' %}?return_url={{ device.get_absolute_url }}%23tab_consoleports" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button>
<button type="submit" name="_edit" formaction="{% url 'dcim:consoleport_bulk_edit' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<button type="submit" name="_edit" formaction="{% url 'dcim:consoleport_bulk_edit' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}%23tab_consoleports" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
</button>
<button type="submit" name="_disconnect" formaction="{% url 'dcim:consoleport_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<button type="submit" name="_disconnect" formaction="{% url 'dcim:consoleport_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}%23tab_consoleports" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</button>
{% endif %}
{% if consoleports and perms.dcim.delete_consoleport %}
<button type="submit" name="_delete" formaction="{% url 'dcim:consoleport_bulk_delete' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<button type="submit" name="_delete" formaction="{% url 'dcim:consoleport_bulk_delete' %}?return_url={{ device.get_absolute_url }}%23tab_consoleports" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button>
{% endif %}
{% if perms.dcim.add_consoleport %}
<div class="pull-right">
<a href="{% url 'dcim:consoleport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-xs btn-primary">
<a href="{% url 'dcim:consoleport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}%23tab_consoleports" class="btn btn-xs btn-primary">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console port
</a>
</div>
@@ -764,24 +763,24 @@
</table>
<div class="panel-footer noprint">
{% if consoleserverports and perms.dcim.change_consoleport %}
<button type="submit" name="_rename" formaction="{% url 'dcim:consoleserverport_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<button type="submit" name="_rename" formaction="{% url 'dcim:consoleserverport_bulk_rename' %}?return_url={{ device.get_absolute_url }}%23tab_consoleserverports" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button>
<button type="submit" name="_edit" formaction="{% url 'dcim:consoleserverport_bulk_edit' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<button type="submit" name="_edit" formaction="{% url 'dcim:consoleserverport_bulk_edit' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}%23tab_consoleserverports" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
</button>
<button type="submit" name="_disconnect" formaction="{% url 'dcim:consoleserverport_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<button type="submit" name="_disconnect" formaction="{% url 'dcim:consoleserverport_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}%23tab_consoleserverports" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</button>
{% endif %}
{% if consoleserverports and perms.dcim.delete_consoleserverport %}
<button type="submit" formaction="{% url 'dcim:consoleserverport_bulk_delete' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<button type="submit" formaction="{% url 'dcim:consoleserverport_bulk_delete' %}?return_url={{ device.get_absolute_url }}%23tab_consoleserverports" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button>
{% endif %}
{% if perms.dcim.add_consoleserverport %}
<div class="pull-right">
<a href="{% url 'dcim:consoleserverport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-primary btn-xs">
<a href="{% url 'dcim:consoleserverport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}%23tab_consoleserverports" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console server ports
</a>
</div>
@@ -819,24 +818,24 @@
</table>
<div class="panel-footer noprint">
{% if powerports and perms.dcim.change_powerport %}
<button type="submit" name="_rename" formaction="{% url 'dcim:powerport_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<button type="submit" name="_rename" formaction="{% url 'dcim:powerport_bulk_rename' %}?return_url={{ device.get_absolute_url }}%23tab_powerports" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button>
<button type="submit" name="_edit" formaction="{% url 'dcim:powerport_bulk_edit' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<button type="submit" name="_edit" formaction="{% url 'dcim:powerport_bulk_edit' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}%23tab_powerports" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
</button>
<button type="submit" name="_disconnect" formaction="{% url 'dcim:powerport_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<button type="submit" name="_disconnect" formaction="{% url 'dcim:powerport_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}%23tab_powerports" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</button>
{% endif %}
{% if powerports and perms.dcim.delete_powerport %}
<button type="submit" name="_delete" formaction="{% url 'dcim:powerport_bulk_delete' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<button type="submit" name="_delete" formaction="{% url 'dcim:powerport_bulk_delete' %}?return_url={{ device.get_absolute_url }}%23tab_powerports" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button>
{% endif %}
{% if perms.dcim.add_powerport %}
<div class="pull-right">
<a href="{% url 'dcim:powerport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-xs btn-primary">
<a href="{% url 'dcim:powerport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}%23tab_powerports" class="btn btn-xs btn-primary">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power port
</a>
</div>
@@ -875,24 +874,24 @@
</table>
<div class="panel-footer noprint">
{% if poweroutlets and perms.dcim.change_powerport %}
<button type="submit" name="_rename" formaction="{% url 'dcim:poweroutlet_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<button type="submit" name="_rename" formaction="{% url 'dcim:poweroutlet_bulk_rename' %}?return_url={{ device.get_absolute_url }}%23tab_poweroutlets" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button>
<button type="submit" name="_edit" formaction="{% url 'dcim:poweroutlet_bulk_edit' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<button type="submit" name="_edit" formaction="{% url 'dcim:poweroutlet_bulk_edit' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}%23tab_poweroutlets" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
</button>
<button type="submit" name="_disconnect" formaction="{% url 'dcim:poweroutlet_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<button type="submit" name="_disconnect" formaction="{% url 'dcim:poweroutlet_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}%23tab_poweroutlets" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</button>
{% endif %}
{% if poweroutlets and perms.dcim.delete_poweroutlet %}
<button type="submit" formaction="{% url 'dcim:poweroutlet_bulk_delete' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<button type="submit" formaction="{% url 'dcim:poweroutlet_bulk_delete' %}?return_url={{ device.get_absolute_url }}%23tab_poweroutlets" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button>
{% endif %}
{% if perms.dcim.add_poweroutlet %}
<div class="pull-right">
<a href="{% url 'dcim:poweroutlet_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-primary btn-xs">
<a href="{% url 'dcim:poweroutlet_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}%23tab_poweroutlets" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power outlets
</a>
</div>
@@ -934,18 +933,18 @@
</table>
<div class="panel-footer noprint">
{% if devicebays and perms.dcim.change_devicebay %}
<button type="submit" name="_rename" formaction="{% url 'dcim:devicebay_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<button type="submit" name="_rename" formaction="{% url 'dcim:devicebay_bulk_rename' %}?return_url={{ device.get_absolute_url }}%23tab_devicebays" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button>
{% endif %}
{% if devicebays and perms.dcim.delete_devicebay %}
<button type="submit" formaction="{% url 'dcim:devicebay_bulk_delete' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<button type="submit" formaction="{% url 'dcim:devicebay_bulk_delete' %}?return_url={{ device.get_absolute_url }}%23tab_devicebays" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete selected
</button>
{% endif %}
{% if perms.dcim.add_devicebay %}
<div class="pull-right">
<a href="{% url 'dcim:devicebay_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-primary btn-xs">
<a href="{% url 'dcim:devicebay_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}%23tab_devicebays" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add device bays
</a>
</div>
@@ -964,6 +963,16 @@
{% block javascript %}
<script type="text/javascript">
// Redirect user to appropriate components tab if specified
var hash = document.location.hash;
var prefix = "tab_";
if (hash) {
$('.nav-tabs a[href="'+hash.replace(prefix,"")+'"]').tab('show');
}
$('.nav-tabs a').on('shown.bs.tab', function (e) {
window.location.hash = e.target.hash.replace("#", "#" + prefix);
});
function toggleConnection(elem) {
var url = netbox_api_path + "dcim/cables/" + elem.attr('data') + "/";
if (elem.hasClass('connected')) {

View File

@@ -0,0 +1,16 @@
{% extends 'utilities/obj_edit.html' %}
{% load form_helpers %}
{% block form_fields %}
{% if form.instance.device %}
<div class="form-group">
<label class="col-md-3 control-label required" for="id_device">Device</label>
<div class="col-md-9">
<p class="form-control-static">
<a href="{{ form.instance.device.get_absolute_url }}">{{ form.instance.device }}</a>
</p>
</div>
</div>
{% endif %}
{% render_form form %}
{% endblock %}

View File

@@ -23,6 +23,7 @@
<div class="panel-body">
{% render_field form.region %}
{% render_field form.site %}
{% render_field form.rack_group %}
{% render_field form.rack %}
{% if obj.device_type.is_child_device and obj.parent_bay %}
<div class="form-group">

View File

@@ -24,14 +24,30 @@
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add Components <span class="caret"></span>
</button>
<ul class="dropdown-menu">
{% if perms.dcim.add_consoleporttemplate %}<li><a href="{% url 'dcim:consoleporttemplate_add' %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}">Console Ports</a></li>{% endif %}
{% if perms.dcim.add_consoleserverporttemplate %}<li><a href="{% url 'dcim:consoleserverporttemplate_add' %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}">Console Server Ports</a></li>{% endif %}
{% if perms.dcim.add_powerporttemplate %}<li><a href="{% url 'dcim:powerporttemplate_add' %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}">Power Ports</a></li>{% endif %}
{% if perms.dcim.add_poweroutlettemplate %}<li><a href="{% url 'dcim:poweroutlettemplate_add' %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}">Power Outlets</a></li>{% endif %}
{% if perms.dcim.add_interfacetemplate %}<li><a href="{% url 'dcim:interfacetemplate_add' %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}">Interfaces</a></li>{% endif %}
{% if perms.dcim.add_frontporttemplate %}<li><a href="{% url 'dcim:frontporttemplate_add' %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}">Front Ports</a></li>{% endif %}
{% if perms.dcim.add_rearporttemplate %}<li><a href="{% url 'dcim:rearporttemplate_add' %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}">Rear Ports</a></li>{% endif %}
{% if perms.dcim.add_devicebaytemplate %}<li><a href="{% url 'dcim:devicebaytemplate_add' %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}">Device Bays</a></li>{% endif %}
{% if perms.dcim.add_consoleporttemplate %}
<li><a href="{% url 'dcim:consoleporttemplate_add' %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}%23tab_consoleports">Console Ports</a></li>
{% endif %}
{% if perms.dcim.add_consoleserverporttemplate %}
<li><a href="{% url 'dcim:consoleserverporttemplate_add' %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}%23tab_consoleserverports">Console Server Ports</a></li>
{% endif %}
{% if perms.dcim.add_powerporttemplate %}
<li><a href="{% url 'dcim:powerporttemplate_add' %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}%23tab_powerports">Power Ports</a></li>
{% endif %}
{% if perms.dcim.add_poweroutlettemplate %}
<li><a href="{% url 'dcim:poweroutlettemplate_add' %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}%23tab_poweroutlets">Power Outlets</a></li>
{% endif %}
{% if perms.dcim.add_interfacetemplate %}
<li><a href="{% url 'dcim:interfacetemplate_add' %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}%23tab_interfaces">Interfaces</a></li>
{% endif %}
{% if perms.dcim.add_frontporttemplate %}
<li><a href="{% url 'dcim:frontporttemplate_add' %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}%23tab_frontports">Front Ports</a></li>
{% endif %}
{% if perms.dcim.add_rearporttemplate %}
<li><a href="{% url 'dcim:rearporttemplate_add' %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}%23tab_rearports">Rear Ports</a></li>
{% endif %}
{% if perms.dcim.add_devicebaytemplate %}
<li><a href="{% url 'dcim:devicebaytemplate_add' %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}%23tab_devicebays">Device Bays</a></li>
{% endif %}
</ul>
</div>
{% endif %}
@@ -217,3 +233,17 @@
</div>
</div>
{% endblock %}
{% block javascript %}
<script type="text/javascript">
// Redirect user to appropriate components tab if specified
var hash = document.location.hash;
var prefix = "tab_";
if (hash) {
$('.nav-tabs a[href="'+hash.replace(prefix,"")+'"]').tab('show');
}
$('.nav-tabs a').on('shown.bs.tab', function (e) {
window.location.hash = e.target.hash.replace("#", "#" + prefix);
});
</script>
{% endblock %}

View File

@@ -66,7 +66,7 @@
</span>
{% endif %}
{% if perms.dcim.change_consoleport %}
<a href="{% url 'dcim:consoleport_edit' pk=cp.pk %}" title="Edit port" class="btn btn-info btn-xs">
<a href="{% url 'dcim:consoleport_edit' pk=cp.pk %}?return_url={{ device.get_absolute_url }}" title="Edit port" class="btn btn-info btn-xs">
<i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
</a>
{% endif %}

View File

@@ -68,7 +68,7 @@
</span>
{% endif %}
{% if perms.dcim.change_consoleserverport %}
<a href="{% url 'dcim:consoleserverport_edit' pk=csp.pk %}" title="Edit port" class="btn btn-info btn-xs">
<a href="{% url 'dcim:consoleserverport_edit' pk=csp.pk %}?return_url={{ device.get_absolute_url }}" title="Edit port" class="btn btn-info btn-xs">
<i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
</a>
{% endif %}

View File

@@ -52,7 +52,7 @@
<i class="glyphicon glyphicon-plus" aria-hidden="true" title="Install device"></i>
</a>
{% endif %}
<a href="{% url 'dcim:devicebay_edit' pk=devicebay.pk %}" class="btn btn-info btn-xs">
<a href="{% url 'dcim:devicebay_edit' pk=devicebay.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-info btn-xs">
<i class="glyphicon glyphicon-pencil" aria-hidden="true" title="Edit device bay"></i>
</a>
{% endif %}

View File

@@ -65,7 +65,7 @@
LAG interface<br />
<small class="text-muted">
{% for member in iface.member_interfaces.all %}
<a href="#interface_{{ member.name }}">{{ member }}</a>{% if not forloop.last %}, {% endif %}
<a href="{{ member.get_absolute_url }}">{{ member }}</a>{% if not forloop.last %}, {% endif %}
{% empty %}
No members
{% endfor %}

View File

@@ -81,7 +81,7 @@
</a>
{% endif %}
{% if perms.dcim.change_poweroutlet %}
<a href="{% url 'dcim:poweroutlet_edit' pk=po.pk %}" title="Edit outlet" class="btn btn-info btn-xs">
<a href="{% url 'dcim:poweroutlet_edit' pk=po.pk %}?return_url={{ device.get_absolute_url }}" title="Edit outlet" class="btn btn-info btn-xs">
<i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
</a>
{% endif %}

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