Compare commits

...

143 Commits

Author SHA1 Message Date
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
110 changed files with 1662 additions and 440 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 NetBox installation, or if you have a general question, DO NOT open an
issue. Instead, post to our mailing list: 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 Please describe the environment in which you are running NetBox. Be sure
that you are running an unmodified instance of the latest stable release 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 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 about: Please read through our contributing policy before opening an issue or pull request
- name: 💬 Discussion Group - 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 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 If you have a general idea or question, please post to our mailing list
instead of opening an issue: 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 NOTE: Due to an excessive backlog of feature requests, we are not currently
accepting any proposals which significantly extend NetBox's feature scope. 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. before submitting a bug report.
--> -->
### Environment ### Environment
* Python version: <!-- Example: 3.6.9 --> * Python version:
* NetBox version: <!-- Example: 2.7.3 --> * NetBox version:
<!-- <!--
Describe in detail the new functionality you are proposing. Include any Describe in detail the new functionality you are proposing. Include any

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 We have established a Google Groups Mailing List for issues and general
discussion. This is the best forum for obtaining assistance with NetBox 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 ### Slack
@@ -164,7 +164,7 @@ overlooked.
* Official channels for communication include: * Official channels for communication include:
* GitHub issues/pull requests * 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/) * The **#netbox** channel on [NetworkToCode Slack](https://networktocode.slack.com/)
* Maintainers with no substantial recorded activity in a 60-day period will be * 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/). 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/)! or join us in the #netbox Slack channel on [NetworkToCode](https://networktocode.slack.com/)!
### Build Status ### Build Status
@@ -44,7 +44,7 @@ and run `upgrade.sh`.
Feature requests and bug reports must be submitted as GiHub issues. (Please be 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).) 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 If you are interested in contributing to the development of NetBox, please read
our [contributing guide](CONTRIBUTING.md) prior to beginning any work. 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. 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 ## 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. 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 * `min_prefix_length` - Minimum length of the mask
* `max_prefix_length` - Maximum 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 ## Example
Below is an example script that creates new objects for a planned site. The user is prompted for three variables: 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 ## 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 ### 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.) 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.
Once a report has been run, its associated results will be included in the report view.
### Via the API ### Via the API

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 ## SCRIPTS_ROOT
Default: `$INSTALL_ROOT/netbox/scripts/` 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) * `PORT` - TCP port of the Redis service; leave blank for default port (6379)
* `PASSWORD` - Redis password (if set) * `PASSWORD` - Redis password (if set)
* `DATABASE` - Numeric database ID * `DATABASE` - Numeric database ID
* `DEFAULT_TIMEOUT` - Connection timeout in seconds
* `SSL` - Use SSL connection to Redis * `SSL` - Use SSL connection to Redis
An example configuration is provided below: An example configuration is provided below:
@@ -77,7 +76,6 @@ REDIS = {
'PORT': 1234, 'PORT': 1234,
'PASSWORD': 'foobar', 'PASSWORD': 'foobar',
'DATABASE': 0, 'DATABASE': 0,
'DEFAULT_TIMEOUT': 300,
'SSL': False, 'SSL': False,
}, },
'caching': { 'caching': {
@@ -85,7 +83,6 @@ REDIS = {
'PORT': 6379, 'PORT': 6379,
'PASSWORD': '', 'PASSWORD': '',
'DATABASE': 1, 'DATABASE': 1,
'DEFAULT_TIMEOUT': 300,
'SSL': False, '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 * `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 of the Redis server and port for each sentinel instance to connect to
* `SENTINEL_SERVICE`: Name of the master / service to connect to * `SENTINEL_SERVICE`: Name of the master / service to connect to
* `SENTINEL_TIMEOUT`: Connection timeout, in seconds
Example: Example:
@@ -117,9 +115,9 @@ REDIS = {
'tasks': { 'tasks': {
'SENTINELS': [('mysentinel.redis.example.com', 6379)], 'SENTINELS': [('mysentinel.redis.example.com', 6379)],
'SENTINEL_SERVICE': 'netbox', 'SENTINEL_SERVICE': 'netbox',
'SENTINEL_TIMEOUT': 10,
'PASSWORD': '', 'PASSWORD': '',
'DATABASE': 0, 'DATABASE': 0,
'DEFAULT_TIMEOUT': 300,
'SSL': False, 'SSL': False,
}, },
'caching': { 'caching': {
@@ -130,7 +128,6 @@ REDIS = {
'SENTINEL_SERVICE': 'netbox', 'SENTINEL_SERVICE': 'netbox',
'PASSWORD': '', 'PASSWORD': '',
'DATABASE': 1, 'DATABASE': 1,
'DEFAULT_TIMEOUT': 300,
'SSL': False, '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: 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. * [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. * [#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 ## 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' 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: Before continuing with either platform, update pip (Python's package management tool) to its latest release:
```no-highlight ```no-highlight
# pip install --upgrade pip # pip3 install --upgrade pip
``` ```
## Download NetBox ## Download NetBox
@@ -163,7 +163,6 @@ REDIS = {
'PORT': 6379, # Redis port 'PORT': 6379, # Redis port
'PASSWORD': '', # Redis password (optional) 'PASSWORD': '', # Redis password (optional)
'DATABASE': 0, # Database ID 'DATABASE': 0, # Database ID
'DEFAULT_TIMEOUT': 300, # Timeout (seconds)
'SSL': False, # Use SSL (optional) 'SSL': False, # Use SSL (optional)
}, },
'caching': { 'caching': {
@@ -171,7 +170,6 @@ REDIS = {
'PORT': 6379, 'PORT': 6379,
'PASSWORD': '', 'PASSWORD': '',
'DATABASE': 1, # Unique ID for second database 'DATABASE': 1, # Unique ID for second database
'DEFAULT_TIMEOUT': 300,
'SSL': False, '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`. `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 ```python
import logging, logging.handlers LOGGING = {
logfile = "/opt/netbox/logs/django-ldap-debug.log" 'version': 1,
my_logger = logging.getLogger('django_auth_ldap') 'disable_existing_loggers': False,
my_logger.setLevel(logging.DEBUG) 'handlers': {
handler = logging.handlers.RotatingFileHandler( 'netbox_auth_log': {
logfile, maxBytes=1024 * 500, backupCount=5 'level': 'DEBUG',
) 'class': 'logging.handlers.RotatingFileHandler',
my_logger.addHandler(handler) '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. 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 # systemctl status netbox.service
● netbox.service - NetBox WSGI Service ● netbox.service - NetBox WSGI Service
Loaded: loaded (/etc/systemd/system/netbox.service; enabled; vendor preset: enabled) 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/ Docs: https://netbox.readthedocs.io/en/stable/
Main PID: 11993 (gunicorn) Main PID: 11993 (gunicorn)
Tasks: 6 (limit: 2362) Tasks: 6 (limit: 2362)
CGroup: /system.slice/netbox.service CGroup: /system.slice/netbox.service
├─11993 /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 /usr/bin/python3 /usr/local/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 /usr/bin/python3 /usr/local/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

@@ -28,44 +28,44 @@ Download and extract the latest version:
# wget https://github.com/netbox-community/netbox/archive/vX.Y.Z.tar.gz # wget https://github.com/netbox-community/netbox/archive/vX.Y.Z.tar.gz
# tar -xzf vX.Y.Z.tar.gz -C /opt # tar -xzf vX.Y.Z.tar.gz -C /opt
# cd /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: Copy the 'configuration.py' you created when first installing to the new version:
```no-highlight ```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: Copy your local requirements file if used:
```no-highlight ```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: Also copy the LDAP configuration if using LDAP:
```no-highlight ```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.) 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 ```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.) 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 ```no-highlight
# cp -r /opt/netbox-X.Y.Z/netbox/scripts /opt/netbox/netbox/scripts/ # cp -r /opt/netbox-X.Y.Z/netbox/scripts /opt/netbox/netbox/
# cp -r /opt/netbox-X.Y.Z/netbox/reports /opt/netbox/netbox/reports/ # 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: If you followed the original installation guide to set up gunicorn, be sure to copy its configuration as well:
```no-highlight ```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 ### Option B: Clone the Git Repository

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

View File

@@ -1,3 +1,3 @@
## Rear Port Templates ## 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

@@ -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. 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 ## Initial Setup
## Plugin Structure ## Plugin Structure
@@ -198,14 +201,25 @@ 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 ```jinja2
{% extends 'base.html' %} {% extends 'base.html' %}
{% block content %} {% block content %}
{% with config=settings.PLUGINS_CONFIG.netbox_animal_sounds %} {% with config=settings.PLUGINS_CONFIG.netbox_animal_sounds %}
<h2 class="text-center" style="margin-top: 200px"> <h2 class="text-center" style="margin-top: 200px">
{% if animal %} {% if animal %}
The {{ animal.name|lower }} says The {{ animal.name|lower }} says
{% if config.loud %} {% if config.loud %}
@@ -216,8 +230,8 @@ This view retrieves a random animal from the database and and passes it as a con
{% else %} {% else %}
No animals have been created yet! No animals have been created yet!
{% endif %} {% endif %}
</h2> </h2>
{% endwith %} {% endwith %}
{% endblock %} {% endblock %}
``` ```
@@ -328,6 +342,9 @@ A `PluginMenuButton` has the following attributes:
* `color` - One of the choices provided by `ButtonColorChoices` (optional) * `color` - One of the choices provided by `ButtonColorChoices` (optional)
* `permissions` - A list of permissions required to display this button (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 ## 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: 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 ### 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: 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,5 +1,145 @@
# NetBox v2.9 # NetBox v2.9
## v2.9.8 (2020-10-30)
### Enhancements
* [#4559](https://github.com/netbox-community/netbox/issues/4559) - Improve device/VM context data rendering performance
### Bug Fixes
* [#3672](https://github.com/netbox-community/netbox/issues/3672) - Fix a caching issue causing incorrect related object counts in API responses
* [#5113](https://github.com/netbox-community/netbox/issues/5113) - Fix incorrect caching of permission object assignments to user groups in the admin panel
* [#5243](https://github.com/netbox-community/netbox/issues/5243) - Redirect user to appropriate tab after modifying device components
* [#5273](https://github.com/netbox-community/netbox/issues/5273) - Fix exception when validating a new permission with no models selected
* [#5282](https://github.com/netbox-community/netbox/issues/5282) - Fix high CPU load when LDAP authentication is enabled
* [#5285](https://github.com/netbox-community/netbox/issues/5285) - Plugins no longer need to define `app_name` for API URLs to be included in the root view
---
## v2.9.7 (2020-10-12)
### Bug Fixes
* [#5231](https://github.com/netbox-community/netbox/issues/5231) - Fix KeyError exception when viewing object with custom link and debugging is disabled
---
## v2.9.6 (2020-10-09)
### Bug Fixes
* [#5229](https://github.com/netbox-community/netbox/issues/5229) - Fix AttributeError exception when LDAP authentication is enabled
---
## v2.9.5 (2020-10-09)
### Enhancements
* [#5202](https://github.com/netbox-community/netbox/issues/5202) - Extend the available context data when rendering custom links
### Bug Fixes
* [#4523](https://github.com/netbox-community/netbox/issues/4523) - Populate site vlan list when bulk editing interfaces under certain circumstances
* [#5174](https://github.com/netbox-community/netbox/issues/5174) - Ensure consistent alignment of rack elevations
* [#5175](https://github.com/netbox-community/netbox/issues/5175) - Fix toggling of rack elevation order
* [#5184](https://github.com/netbox-community/netbox/issues/5184) - Fix missing Power Utilization
* [#5197](https://github.com/netbox-community/netbox/issues/5197) - Limit duplicate IPs shown on IP address view
* [#5199](https://github.com/netbox-community/netbox/issues/5199) - Change default LDAP logging to INFO
* [#5201](https://github.com/netbox-community/netbox/issues/5201) - Fix missing querystring when bulk editing/deleting VLAN Group VLANs when selecting "select all x items matching query"
* [#5206](https://github.com/netbox-community/netbox/issues/5206) - Apply user pagination preferences to all paginated object lists
* [#5211](https://github.com/netbox-community/netbox/issues/5211) - Add missing `has_primary_ip` filter for virtual machines
* [#5217](https://github.com/netbox-community/netbox/issues/5217) - Prevent erroneous removal of prefetched GenericForeignKey data from tables
* [#5218](https://github.com/netbox-community/netbox/issues/5218) - Raise validation error if a power port's `allocated_draw` exceeds its `maximum_draw`
* [#5220](https://github.com/netbox-community/netbox/issues/5220) - Fix API patch request against IP Address endpoint with null assigned_object_type
* [#5221](https://github.com/netbox-community/netbox/issues/5221) - Fix bulk component creation for virtual machines
* [#5224](https://github.com/netbox-community/netbox/issues/5224) - Don't allow a rear port to have fewer positions than the number of mapped front ports
* [#5226](https://github.com/netbox-community/netbox/issues/5226) - Custom choice fields should be blank initially if no default choice has been designated
---
## v2.9.4 (2020-09-23)
**NOTE:** This release removes support for the `DEFAULT_TIMEOUT` parameter under `REDIS` database configuration. Set `RQ_DEFAULT_TIMEOUT` as a global configuration parameter instead.
**NOTE:** Any permissions referencing the legacy ReportResult model (e.g. `extras.view_reportresult`) should be updated to reference the Report model.
### Enhancements
* [#1755](https://github.com/netbox-community/netbox/issues/1755) - Toggle order in which rack elevations are displayed
* [#5128](https://github.com/netbox-community/netbox/issues/5128) - Increase maximum rear port positions from 64 to 1024
* [#5134](https://github.com/netbox-community/netbox/issues/5134) - Display full hierarchy in breadcrumbs for sites/racks
* [#5149](https://github.com/netbox-community/netbox/issues/5149) - Add rack group field to device edit form
* [#5164](https://github.com/netbox-community/netbox/issues/5164) - Show total rack count per rack group under site view
* [#5171](https://github.com/netbox-community/netbox/issues/5171) - Introduce the `RQ_DEFAULT_TIMEOUT` configuration parameter
### Bug Fixes
* [#5050](https://github.com/netbox-community/netbox/issues/5050) - Fix potential failure on `0016_replicate_interfaces` schema migration from old release
* [#5066](https://github.com/netbox-community/netbox/issues/5066) - Update `view_reportresult` to `view_report` permission
* [#5075](https://github.com/netbox-community/netbox/issues/5075) - Include a VLAN membership view for VM interfaces
* [#5105](https://github.com/netbox-community/netbox/issues/5105) - Validation should fail when reassigning a primary IP from device to VM
* [#5109](https://github.com/netbox-community/netbox/issues/5109) - Fix representation of custom choice field values for webhook data
* [#5108](https://github.com/netbox-community/netbox/issues/5108) - Fix execution of reports via CLI
* [#5111](https://github.com/netbox-community/netbox/issues/5111) - Allow use of tuples when specifying ObjectVar `query_params`
* [#5118](https://github.com/netbox-community/netbox/issues/5118) - Specifying an empty list of tags should clear assigned tags (REST API)
* [#5133](https://github.com/netbox-community/netbox/issues/5133) - Fix disassociation of an IP address from a VM interface
* [#5136](https://github.com/netbox-community/netbox/issues/5136) - Fix exception when bulk editing interface 802.1Q mode
* [#5156](https://github.com/netbox-community/netbox/issues/5156) - Add missing "add" button to rack reservations list
* [#5167](https://github.com/netbox-community/netbox/issues/5167) - Support filtering ObjectChanges by multiple users
---
## v2.9.3 (2020-09-04)
### Enhancements
* [#4977](https://github.com/netbox-community/netbox/issues/4977) - Redirect authenticated users from login view
* [#5048](https://github.com/netbox-community/netbox/issues/5048) - Show the device/VM name when editing a component
* [#5072](https://github.com/netbox-community/netbox/issues/5072) - Add REST API filters for image attachments
* [#5080](https://github.com/netbox-community/netbox/issues/5080) - Add 8P6C, 8P4C, 8P2C port types
### Bug Fixes
* [#5046](https://github.com/netbox-community/netbox/issues/5046) - Disabled plugin menu items are no longer clickable
* [#5063](https://github.com/netbox-community/netbox/issues/5063) - Fix "add device" link in rack elevations for opposite side of half-depth devices
* [#5074](https://github.com/netbox-community/netbox/issues/5074) - Fix inclusion of VC member interfaces when viewing VC master
* [#5078](https://github.com/netbox-community/netbox/issues/5078) - Fix assignment of existing IP addresses to interfaces via web UI
* [#5081](https://github.com/netbox-community/netbox/issues/5081) - Fix exception during webhook processing with custom select field
* [#5085](https://github.com/netbox-community/netbox/issues/5085) - Fix ordering by assignment in IP addresses table
* [#5087](https://github.com/netbox-community/netbox/issues/5087) - Restore label field when editing console server ports, power ports, and power outlets
* [#5089](https://github.com/netbox-community/netbox/issues/5089) - Redirect to device view after editing component
* [#5090](https://github.com/netbox-community/netbox/issues/5090) - Fix status display for console/power/interface connections
* [#5091](https://github.com/netbox-community/netbox/issues/5091) - Avoid KeyError when handling invalid table preferences
* [#5095](https://github.com/netbox-community/netbox/issues/5095) - Show assigned prefixes in VLANs list
---
## v2.9.2 (2020-08-27)
### Enhancements
* [#5055](https://github.com/netbox-community/netbox/issues/5055) - Add tags column to device/VM component list tables
* [#5056](https://github.com/netbox-community/netbox/issues/5056) - Add interface and parent columns to IP address list
### Bug Fixes
* [#4988](https://github.com/netbox-community/netbox/issues/4988) - Fix ordering of rack reservations with identical creation times
* [#5002](https://github.com/netbox-community/netbox/issues/5002) - Correct OpenAPI definition for `available-prefixes` endpoint
* [#5035](https://github.com/netbox-community/netbox/issues/5035) - Fix exception when modifying an IP address assigned to a VM
* [#5038](https://github.com/netbox-community/netbox/issues/5038) - Fix validation of primary IPs assigned to virtual machines
* [#5040](https://github.com/netbox-community/netbox/issues/5040) - Limit SLAAC status to IPv6 addresses
* [#5041](https://github.com/netbox-community/netbox/issues/5041) - Fix form tabs when assigning an IP to a VM interface
* [#5042](https://github.com/netbox-community/netbox/issues/5042) - Fix display of SLAAC label for IP addresses status
* [#5045](https://github.com/netbox-community/netbox/issues/5045) - Allow assignment of interfaces to non-master VC peer LAG during import
* [#5058](https://github.com/netbox-community/netbox/issues/5058) - Correct URL for front rack elevation images when using external storage
* [#5059](https://github.com/netbox-community/netbox/issues/5059) - Fix inclusion of checkboxes for interfaces in virtual machine view
* [#5060](https://github.com/netbox-community/netbox/issues/5060) - Fix validation when bulk-importing child devices
* [#5061](https://github.com/netbox-community/netbox/issues/5061) - Allow adding/removing tags when bulk editing virtual machine interfaces
---
## v2.9.1 (2020-08-22) ## v2.9.1 (2020-08-22)
### Enhancements ### Enhancements
@@ -72,6 +212,7 @@ Two new REST API endpoints have been added to facilitate the retrieval and manip
* 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 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.) * 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}`. * `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 ### REST API Changes

View File

@@ -1,13 +1,12 @@
from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.db import transaction 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.shortcuts import get_object_or_404, redirect, render
from django_tables2 import RequestConfig from django_tables2 import RequestConfig
from extras.models import Graph from extras.models import Graph
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.views import ( from utilities.views import (
BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView, BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
) )
@@ -45,7 +44,7 @@ class ProviderView(ObjectView):
paginate = { paginate = {
'paginator_class': EnhancedPaginator, '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) RequestConfig(request, paginate).configure(circuits_table)

View File

@@ -24,7 +24,7 @@ from dcim.models import (
VirtualChassis, VirtualChassis,
) )
from extras.api.serializers import RenderedGraphSerializer 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 extras.models import Graph
from ipam.models import Prefix, VLAN from ipam.models import Prefix, VLAN
from utilities.api import ( from utilities.api import (
@@ -336,7 +336,7 @@ class PlatformViewSet(ModelViewSet):
# Devices # Devices
# #
class DeviceViewSet(CustomFieldModelViewSet): class DeviceViewSet(CustomFieldModelViewSet, ConfigContextQuerySetMixin):
queryset = Device.objects.prefetch_related( queryset = Device.objects.prefetch_related(
'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'parent_bay', 'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'parent_bay',
'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags', 'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags',

View File

@@ -814,6 +814,9 @@ class InterfaceModeChoices(ChoiceSet):
class PortTypeChoices(ChoiceSet): class PortTypeChoices(ChoiceSet):
TYPE_8P8C = '8p8c' TYPE_8P8C = '8p8c'
TYPE_8P6C = '8p6c'
TYPE_8P4C = '8p4c'
TYPE_8P2C = '8p2c'
TYPE_110_PUNCH = '110-punch' TYPE_110_PUNCH = '110-punch'
TYPE_BNC = 'bnc' TYPE_BNC = 'bnc'
TYPE_MRJ21 = 'mrj21' TYPE_MRJ21 = 'mrj21'
@@ -833,6 +836,9 @@ class PortTypeChoices(ChoiceSet):
'Copper', 'Copper',
( (
(TYPE_8P8C, '8P8C'), (TYPE_8P8C, '8P8C'),
(TYPE_8P6C, '8P6C'),
(TYPE_8P4C, '8P4C'),
(TYPE_8P2C, '8P2C'),
(TYPE_110_PUNCH, '110 Punch'), (TYPE_110_PUNCH, '110 Punch'),
(TYPE_BNC, 'BNC'), (TYPE_BNC, 'BNC'),
(TYPE_MRJ21, 'MRJ21'), (TYPE_MRJ21, 'MRJ21'),

View File

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

View File

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

View File

@@ -1680,12 +1680,21 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
'region_id': '$region' 'region_id': '$region'
} }
) )
rack_group = DynamicModelChoiceField(
queryset=RackGroup.objects.all(),
required=False,
display_field='display_name',
query_params={
'site_id': '$site'
}
)
rack = DynamicModelChoiceField( rack = DynamicModelChoiceField(
queryset=Rack.objects.all(), queryset=Rack.objects.all(),
required=False, required=False,
display_field='display_name', display_field='display_name',
query_params={ query_params={
'site_id': '$site' 'site_id': '$site',
'group_id': '$rack_group',
} }
) )
position = forms.TypedChoiceField( position = forms.TypedChoiceField(
@@ -1811,7 +1820,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
nat_inside__assigned_object_id__in=interface_ids nat_inside__assigned_object_id__in=interface_ids
).prefetch_related('assigned_object') ).prefetch_related('assigned_object')
if nat_ips: 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)) ip_choices.append(('NAT IPs', ip_list))
self.fields['primary_ip{}'.format(family)].choices = ip_choices self.fields['primary_ip{}'.format(family)].choices = ip_choices
@@ -2317,7 +2326,7 @@ class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm):
class Meta: class Meta:
model = ConsoleServerPort model = ConsoleServerPort
fields = [ fields = [
'device', 'name', 'type', 'description', 'tags', 'device', 'name', 'label', 'type', 'description', 'tags',
] ]
widgets = { widgets = {
'device': forms.HiddenInput(), 'device': forms.HiddenInput(),
@@ -2390,7 +2399,7 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm):
class Meta: class Meta:
model = PowerPort model = PowerPort
fields = [ fields = [
'device', 'name', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags',
] ]
widgets = { widgets = {
'device': forms.HiddenInput(), 'device': forms.HiddenInput(),
@@ -2479,7 +2488,7 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm):
class Meta: class Meta:
model = PowerOutlet model = PowerOutlet
fields = [ fields = [
'device', 'name', 'type', 'power_port', 'feed_leg', 'description', 'tags', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'tags',
] ]
widgets = { widgets = {
'device': forms.HiddenInput(), 'device': forms.HiddenInput(),
@@ -2835,6 +2844,24 @@ class InterfaceBulkEditForm(
self.fields['untagged_vlan'].widget.add_query_param('site_id', device.site.pk) 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) self.fields['tagged_vlans'].widget.add_query_param('site_id', device.site.pk)
else: 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'].choices = ()
self.fields['lag'].widget.attrs['disabled'] = True self.fields['lag'].widget.attrs['disabled'] = True
@@ -2879,17 +2906,22 @@ class InterfaceCSVForm(CSVModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*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 device = None
if self.is_bound and 'device' in self.data: if self.is_bound and 'device' in self.data:
try: try:
device = self.fields['device'].to_python(self.data['device']) device = self.fields['device'].to_python(self.data['device'])
except forms.ValidationError: except forms.ValidationError:
pass pass
if device and device.virtual_chassis:
if device:
self.fields['lag'].queryset = Interface.objects.filter( 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: else:
self.fields['lag'].queryset = Interface.objects.none() self.fields['lag'].queryset = Interface.objects.none()

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)]),
),
]

View File

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

View File

@@ -405,6 +405,14 @@ class PowerPort(CableTermination, ComponentModel):
self.description, 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 @property
def connected_endpoint(self): def connected_endpoint(self):
""" """
@@ -809,7 +817,10 @@ class FrontPort(CableTermination, ComponentModel):
) )
rear_port_position = models.PositiveSmallIntegerField( rear_port_position = models.PositiveSmallIntegerField(
default=1, default=1,
validators=[MinValueValidator(1), MaxValueValidator(64)] validators=[
MinValueValidator(REARPORT_POSITIONS_MIN),
MaxValueValidator(REARPORT_POSITIONS_MAX)
]
) )
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)
@@ -840,17 +851,16 @@ class FrontPort(CableTermination, ComponentModel):
# Validate rear port assignment # Validate rear port assignment
if self.rear_port.device != self.device: if self.rear_port.device != self.device:
raise ValidationError( raise ValidationError({
"Rear port ({}) must belong to the same device".format(self.rear_port) "rear_port": f"Rear port ({self.rear_port}) must belong to the same device"
) })
# Validate rear port position assignment # Validate rear port position assignment
if self.rear_port_position > self.rear_port.positions: if self.rear_port_position > self.rear_port.positions:
raise ValidationError( raise ValidationError({
"Invalid rear port position ({}); rear port {} has only {} positions".format( "rear_port_position": f"Invalid rear port position ({self.rear_port_position}): Rear port "
self.rear_port_position, self.rear_port.name, self.rear_port.positions f"{self.rear_port.name} has only {self.rear_port.positions} positions"
) })
)
@extras_features('webhooks') @extras_features('webhooks')
@@ -864,7 +874,10 @@ class RearPort(CableTermination, ComponentModel):
) )
positions = models.PositiveSmallIntegerField( positions = models.PositiveSmallIntegerField(
default=1, default=1,
validators=[MinValueValidator(1), MaxValueValidator(64)] validators=[
MinValueValidator(REARPORT_POSITIONS_MIN),
MaxValueValidator(REARPORT_POSITIONS_MAX)
]
) )
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)
@@ -877,6 +890,16 @@ class RearPort(CableTermination, ComponentModel):
def get_absolute_url(self): def get_absolute_url(self):
return reverse('dcim:rearport', kwargs={'pk': self.pk}) 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): def to_csv(self):
return ( return (
self.device.identifier, self.device.identifier,

View File

@@ -15,6 +15,7 @@ from taggit.managers import TaggableManager
from dcim.choices import * from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from extras.models import ChangeLoggedModel, ConfigContextModel, CustomFieldModel, TaggedItem from extras.models import ChangeLoggedModel, ConfigContextModel, CustomFieldModel, TaggedItem
from extras.querysets import ConfigContextModelQuerySet
from extras.utils import extras_features from extras.utils import extras_features
from utilities.choices import ColorChoices from utilities.choices import ColorChoices
from utilities.fields import ColorField, NaturalOrderingField from utilities.fields import ColorField, NaturalOrderingField
@@ -594,7 +595,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
) )
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager() objects = ConfigContextModelQuerySet.as_manager()
csv_headers = [ csv_headers = [
'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status', 'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
@@ -633,7 +634,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
# Check for a duplicate name on a device assigned to the same Site and no Tenant. This is necessary # Check for a duplicate name on a device assigned to the same Site and no Tenant. This is necessary
# because Django does not consider two NULL fields to be equal, and thus will not trigger a violation # because Django does not consider two NULL fields to be equal, and thus will not trigger a violation
# of the uniqueness constraint without manual intervention. # of the uniqueness constraint without manual intervention.
if self.name and self.tenant is None: if self.name and hasattr(self, 'site') and self.tenant is None:
if Device.objects.exclude(pk=self.pk).filter( if Device.objects.exclude(pk=self.pk).filter(
name=self.name, name=self.name,
site=self.site, site=self.site,

View File

@@ -600,7 +600,7 @@ class RackReservation(ChangeLoggedModel):
csv_headers = ['site', 'rack_group', 'rack', 'units', 'tenant', 'user', 'description'] csv_headers = ['site', 'rack_group', 'rack', 'units', 'tenant', 'user', 'description']
class Meta: class Meta:
ordering = ['created'] ordering = ['created', 'pk']
def __str__(self): def __str__(self):
return "Reservation for rack {}".format(self.rack) return "Reservation for rack {}".format(self.rack)

View File

@@ -152,6 +152,10 @@ INTERFACE_TAGGED_VLANS = """
{% endfor %} {% endfor %}
""" """
CONNECTION_STATUS = """
<span class="label label-{% if record.connection_status %}success{% else %}danger{% endif %}">{{ record.get_connection_status_display }}</span>
"""
# #
# Regions # Regions
@@ -428,7 +432,8 @@ class ComponentTemplateTable(BaseTable):
class ConsolePortTemplateTable(ComponentTemplateTable): class ConsolePortTemplateTable(ComponentTemplateTable):
actions = ButtonsColumn( actions = ButtonsColumn(
model=ConsolePortTemplate, model=ConsolePortTemplate,
buttons=('edit', 'delete') buttons=('edit', 'delete'),
return_url_extra='%23tab_consoleports'
) )
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
@@ -440,7 +445,8 @@ class ConsolePortTemplateTable(ComponentTemplateTable):
class ConsoleServerPortTemplateTable(ComponentTemplateTable): class ConsoleServerPortTemplateTable(ComponentTemplateTable):
actions = ButtonsColumn( actions = ButtonsColumn(
model=ConsoleServerPortTemplate, model=ConsoleServerPortTemplate,
buttons=('edit', 'delete') buttons=('edit', 'delete'),
return_url_extra='%23tab_consoleserverports'
) )
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
@@ -452,7 +458,8 @@ class ConsoleServerPortTemplateTable(ComponentTemplateTable):
class PowerPortTemplateTable(ComponentTemplateTable): class PowerPortTemplateTable(ComponentTemplateTable):
actions = ButtonsColumn( actions = ButtonsColumn(
model=PowerPortTemplate, model=PowerPortTemplate,
buttons=('edit', 'delete') buttons=('edit', 'delete'),
return_url_extra='%23tab_powerports'
) )
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
@@ -464,7 +471,8 @@ class PowerPortTemplateTable(ComponentTemplateTable):
class PowerOutletTemplateTable(ComponentTemplateTable): class PowerOutletTemplateTable(ComponentTemplateTable):
actions = ButtonsColumn( actions = ButtonsColumn(
model=PowerOutletTemplate, model=PowerOutletTemplate,
buttons=('edit', 'delete') buttons=('edit', 'delete'),
return_url_extra='%23tab_poweroutlets'
) )
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
@@ -479,7 +487,8 @@ class InterfaceTemplateTable(ComponentTemplateTable):
) )
actions = ButtonsColumn( actions = ButtonsColumn(
model=InterfaceTemplate, model=InterfaceTemplate,
buttons=('edit', 'delete') buttons=('edit', 'delete'),
return_url_extra='%23tab_interfaces'
) )
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
@@ -494,7 +503,8 @@ class FrontPortTemplateTable(ComponentTemplateTable):
) )
actions = ButtonsColumn( actions = ButtonsColumn(
model=FrontPortTemplate, model=FrontPortTemplate,
buttons=('edit', 'delete') buttons=('edit', 'delete'),
return_url_extra='%23tab_frontports'
) )
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
@@ -506,7 +516,8 @@ class FrontPortTemplateTable(ComponentTemplateTable):
class RearPortTemplateTable(ComponentTemplateTable): class RearPortTemplateTable(ComponentTemplateTable):
actions = ButtonsColumn( actions = ButtonsColumn(
model=RearPortTemplate, model=RearPortTemplate,
buttons=('edit', 'delete') buttons=('edit', 'delete'),
return_url_extra='%23tab_rearports'
) )
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
@@ -518,7 +529,8 @@ class RearPortTemplateTable(ComponentTemplateTable):
class DeviceBayTemplateTable(ComponentTemplateTable): class DeviceBayTemplateTable(ComponentTemplateTable):
actions = ButtonsColumn( actions = ButtonsColumn(
model=DeviceBayTemplate, model=DeviceBayTemplate,
buttons=('edit', 'delete') buttons=('edit', 'delete'),
return_url_extra='%23tab_devicebays'
) )
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
@@ -706,34 +718,48 @@ class DeviceComponentTable(BaseTable):
class ConsolePortTable(DeviceComponentTable): class ConsolePortTable(DeviceComponentTable):
tags = TagColumn(
url_name='dcim:consoleport_list'
)
class Meta(DeviceComponentTable.Meta): class Meta(DeviceComponentTable.Meta):
model = ConsolePort 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') default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
class ConsoleServerPortTable(DeviceComponentTable): class ConsoleServerPortTable(DeviceComponentTable):
tags = TagColumn(
url_name='dcim:consoleserverport_list'
)
class Meta(DeviceComponentTable.Meta): class Meta(DeviceComponentTable.Meta):
model = ConsoleServerPort 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') default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
class PowerPortTable(DeviceComponentTable): class PowerPortTable(DeviceComponentTable):
tags = TagColumn(
url_name='dcim:powerport_list'
)
class Meta(DeviceComponentTable.Meta): class Meta(DeviceComponentTable.Meta):
model = PowerPort 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') default_columns = ('pk', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description')
class PowerOutletTable(DeviceComponentTable): class PowerOutletTable(DeviceComponentTable):
tags = TagColumn(
url_name='dcim:poweroutlet_list'
)
class Meta(DeviceComponentTable.Meta): class Meta(DeviceComponentTable.Meta):
model = PowerOutlet 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') default_columns = ('pk', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description')
@@ -753,12 +779,15 @@ class BaseInterfaceTable(BaseTable):
class InterfaceTable(DeviceComponentTable, BaseInterfaceTable): class InterfaceTable(DeviceComponentTable, BaseInterfaceTable):
tags = TagColumn(
url_name='dcim:interface_list'
)
class Meta(DeviceComponentTable.Meta): class Meta(DeviceComponentTable.Meta):
model = Interface model = Interface
fields = ( fields = (
'pk', 'device', 'name', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address', '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') default_columns = ('pk', 'device', 'name', 'label', 'enabled', 'type', 'description')
@@ -767,18 +796,26 @@ class FrontPortTable(DeviceComponentTable):
rear_port_position = tables.Column( rear_port_position = tables.Column(
verbose_name='Position' verbose_name='Position'
) )
tags = TagColumn(
url_name='dcim:frontport_list'
)
class Meta(DeviceComponentTable.Meta): class Meta(DeviceComponentTable.Meta):
model = FrontPort 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') default_columns = ('pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description')
class RearPortTable(DeviceComponentTable): class RearPortTable(DeviceComponentTable):
tags = TagColumn(
url_name='dcim:rearport_list'
)
class Meta(DeviceComponentTable.Meta): class Meta(DeviceComponentTable.Meta):
model = RearPort 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') default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
@@ -786,10 +823,13 @@ class DeviceBayTable(DeviceComponentTable):
installed_device = tables.Column( installed_device = tables.Column(
linkify=True linkify=True
) )
tags = TagColumn(
url_name='dcim:devicebay_list'
)
class Meta(DeviceComponentTable.Meta): class Meta(DeviceComponentTable.Meta):
model = DeviceBay 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') default_columns = ('pk', 'device', 'name', 'label', 'installed_device', 'description')
@@ -798,12 +838,16 @@ class InventoryItemTable(DeviceComponentTable):
linkify=True linkify=True
) )
discovered = BooleanColumn() discovered = BooleanColumn()
tags = TagColumn(
url_name='dcim:inventoryitem_list'
)
cable = None # Override DeviceComponentTable
class Meta(DeviceComponentTable.Meta): class Meta(DeviceComponentTable.Meta):
model = InventoryItem model = InventoryItem
fields = ( fields = (
'pk', 'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', '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') default_columns = ('pk', 'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag')
@@ -876,15 +920,20 @@ class ConsoleConnectionTable(BaseTable):
verbose_name='Console Server' verbose_name='Console Server'
) )
connected_endpoint = tables.Column( connected_endpoint = tables.Column(
linkify=True,
verbose_name='Port' verbose_name='Port'
) )
device = tables.Column( device = tables.Column(
linkify=True linkify=True
) )
name = tables.Column( name = tables.Column(
linkify=True,
verbose_name='Console Port' verbose_name='Console Port'
) )
connection_status = BooleanColumn() connection_status = tables.TemplateColumn(
template_code=CONNECTION_STATUS,
verbose_name='Status'
)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = ConsolePort model = ConsolePort
@@ -901,14 +950,20 @@ class PowerConnectionTable(BaseTable):
) )
outlet = tables.Column( outlet = tables.Column(
accessor=Accessor('_connected_poweroutlet'), accessor=Accessor('_connected_poweroutlet'),
linkify=True,
verbose_name='Outlet' verbose_name='Outlet'
) )
device = tables.Column( device = tables.Column(
linkify=True linkify=True
) )
name = tables.Column( name = tables.Column(
linkify=True,
verbose_name='Power Port' verbose_name='Power Port'
) )
connection_status = tables.TemplateColumn(
template_code=CONNECTION_STATUS,
verbose_name='Status'
)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = PowerPort model = PowerPort
@@ -940,6 +995,10 @@ class InterfaceConnectionTable(BaseTable):
args=[Accessor('_connected_interface__pk')], args=[Accessor('_connected_interface__pk')],
verbose_name='Interface B' verbose_name='Interface B'
) )
connection_status = tables.TemplateColumn(
template_code=CONNECTION_STATUS,
verbose_name='Status'
)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Interface model = Interface

View File

@@ -1,6 +1,5 @@
from collections import OrderedDict from collections import OrderedDict
from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.paginator import EmptyPage, PageNotAnInteger 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 ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
from secrets.models import Secret from secrets.models import Secret
from utilities.forms import ConfirmationForm 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.permissions import get_permission_for_model
from utilities.utils import csv_format, get_subquery from utilities.utils import csv_format, get_subquery
from utilities.views import ( from utilities.views import (
@@ -169,9 +168,13 @@ class SiteView(ObjectView):
'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(terminations__site=site).count(), '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(), '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_groups = RackGroup.objects.add_related_count(
rack_count=Count('racks') 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() show_graphs = Graph.objects.filter(type__model='site').exists()
return render(request, 'dcim/site.html', { return render(request, 'dcim/site.html', {
@@ -310,8 +313,13 @@ class RackElevationListView(ObjectListView):
racks = filters.RackFilterSet(request.GET, self.queryset).qs racks = filters.RackFilterSet(request.GET, self.queryset).qs
total_count = racks.count() total_count = racks.count()
# Determine ordering
reverse = bool(request.GET.get('reverse', False))
if reverse:
racks = racks.reverse()
# Pagination # Pagination
per_page = request.GET.get('per_page', settings.PAGINATE_COUNT) per_page = get_paginate_count(request)
page_number = request.GET.get('page', 1) page_number = request.GET.get('page', 1)
paginator = EnhancedPaginator(racks, per_page) paginator = EnhancedPaginator(racks, per_page)
try: try:
@@ -330,6 +338,7 @@ class RackElevationListView(ObjectListView):
'paginator': paginator, 'paginator': paginator,
'page': page, 'page': page,
'total_count': total_count, 'total_count': total_count,
'reverse': reverse,
'rack_face': rack_face, 'rack_face': rack_face,
'filter_form': forms.RackElevationFilterForm(request.GET), 'filter_form': forms.RackElevationFilterForm(request.GET),
}) })
@@ -408,7 +417,6 @@ class RackReservationListView(ObjectListView):
filterset = filters.RackReservationFilterSet filterset = filters.RackReservationFilterSet
filterset_form = forms.RackReservationFilterForm filterset_form = forms.RackReservationFilterForm
table = tables.RackReservationTable table = tables.RackReservationTable
action_buttons = ('export',)
class RackReservationView(ObjectView): class RackReservationView(ObjectView):
@@ -1033,7 +1041,7 @@ class DeviceView(ObjectView):
) )
# Interfaces # 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('ip_addresses', queryset=IPAddress.objects.restrict(request.user)),
Prefetch('member_interfaces', queryset=Interface.objects.restrict(request.user)), Prefetch('member_interfaces', queryset=Interface.objects.restrict(request.user)),
'lag', '_connected_interface__device', '_connected_circuittermination__circuit', 'cable', 'lag', '_connected_interface__device', '_connected_circuittermination__circuit', 'cable',
@@ -1155,7 +1163,7 @@ class DeviceConfigView(ObjectView):
class DeviceConfigContextView(ObjectConfigContextView): class DeviceConfigContextView(ObjectConfigContextView):
queryset = Device.objects.all() queryset = Device.objects.annotate_config_context_data()
base_template = 'dcim/device.html' base_template = 'dcim/device.html'
@@ -1233,6 +1241,7 @@ class ConsolePortCreateView(ComponentCreateView):
class ConsolePortEditView(ObjectEditView): class ConsolePortEditView(ObjectEditView):
queryset = ConsolePort.objects.all() queryset = ConsolePort.objects.all()
model_form = forms.ConsolePortForm model_form = forms.ConsolePortForm
template_name = 'dcim/device_component_edit.html'
class ConsolePortDeleteView(ObjectDeleteView): class ConsolePortDeleteView(ObjectDeleteView):
@@ -1292,6 +1301,7 @@ class ConsoleServerPortCreateView(ComponentCreateView):
class ConsoleServerPortEditView(ObjectEditView): class ConsoleServerPortEditView(ObjectEditView):
queryset = ConsoleServerPort.objects.all() queryset = ConsoleServerPort.objects.all()
model_form = forms.ConsoleServerPortForm model_form = forms.ConsoleServerPortForm
template_name = 'dcim/device_component_edit.html'
class ConsoleServerPortDeleteView(ObjectDeleteView): class ConsoleServerPortDeleteView(ObjectDeleteView):
@@ -1351,6 +1361,7 @@ class PowerPortCreateView(ComponentCreateView):
class PowerPortEditView(ObjectEditView): class PowerPortEditView(ObjectEditView):
queryset = PowerPort.objects.all() queryset = PowerPort.objects.all()
model_form = forms.PowerPortForm model_form = forms.PowerPortForm
template_name = 'dcim/device_component_edit.html'
class PowerPortDeleteView(ObjectDeleteView): class PowerPortDeleteView(ObjectDeleteView):
@@ -1410,6 +1421,7 @@ class PowerOutletCreateView(ComponentCreateView):
class PowerOutletEditView(ObjectEditView): class PowerOutletEditView(ObjectEditView):
queryset = PowerOutlet.objects.all() queryset = PowerOutlet.objects.all()
model_form = forms.PowerOutletForm model_form = forms.PowerOutletForm
template_name = 'dcim/device_component_edit.html'
class PowerOutletDeleteView(ObjectDeleteView): class PowerOutletDeleteView(ObjectDeleteView):
@@ -1561,6 +1573,7 @@ class FrontPortCreateView(ComponentCreateView):
class FrontPortEditView(ObjectEditView): class FrontPortEditView(ObjectEditView):
queryset = FrontPort.objects.all() queryset = FrontPort.objects.all()
model_form = forms.FrontPortForm model_form = forms.FrontPortForm
template_name = 'dcim/device_component_edit.html'
class FrontPortDeleteView(ObjectDeleteView): class FrontPortDeleteView(ObjectDeleteView):
@@ -1620,6 +1633,7 @@ class RearPortCreateView(ComponentCreateView):
class RearPortEditView(ObjectEditView): class RearPortEditView(ObjectEditView):
queryset = RearPort.objects.all() queryset = RearPort.objects.all()
model_form = forms.RearPortForm model_form = forms.RearPortForm
template_name = 'dcim/device_component_edit.html'
class RearPortDeleteView(ObjectDeleteView): class RearPortDeleteView(ObjectDeleteView):
@@ -1679,6 +1693,7 @@ class DeviceBayCreateView(ComponentCreateView):
class DeviceBayEditView(ObjectEditView): class DeviceBayEditView(ObjectEditView):
queryset = DeviceBay.objects.all() queryset = DeviceBay.objects.all()
model_form = forms.DeviceBayForm model_form = forms.DeviceBayForm
template_name = 'dcim/device_component_edit.html'
class DeviceBayDeleteView(ObjectDeleteView): class DeviceBayDeleteView(ObjectDeleteView):

View File

@@ -158,7 +158,7 @@ class CustomFieldModelSerializer(ValidatedModelSerializer):
instance.custom_fields = {} instance.custom_fields = {}
for field in custom_fields: for field in custom_fields:
value = instance.cf.get(field.name) 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 instance.custom_fields[field.name] = CustomFieldChoiceSerializer(value).data
else: else:
instance.custom_fields[field.name] = value instance.custom_fields[field.name] = value

View File

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

View File

@@ -26,6 +26,29 @@ from utilities.utils import copy_safe_request
from . import serializers 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): class ExtrasRootView(APIRootView):
""" """
Extras API root view Extras API root view
@@ -140,6 +163,7 @@ class ImageAttachmentViewSet(ModelViewSet):
metadata_class = ContentTypeMetadata metadata_class = ContentTypeMetadata
queryset = ImageAttachment.objects.all() queryset = ImageAttachment.objects.all()
serializer_class = serializers.ImageAttachmentSerializer serializer_class = serializers.ImageAttachmentSerializer
filterset_class = filters.ImageAttachmentFilterSet
# #

View File

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

View File

@@ -397,10 +397,11 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
required=False, required=False,
widget=StaticSelect2() widget=StaticSelect2()
) )
user = DynamicModelMultipleChoiceField( user_id = DynamicModelMultipleChoiceField(
queryset=User.objects.all(), queryset=User.objects.all(),
required=False, required=False,
display_field='username', display_field='username',
label='User',
widget=APISelectMultiple( widget=APISelectMultiple(
api_url='/api/users/users/', 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.core.management.base import BaseCommand
from django.utils import timezone 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): class Command(BaseCommand):
@@ -20,15 +25,33 @@ class Command(BaseCommand):
for report in report_list: for report in report_list:
if module_name in options['reports'] or report.full_name in options['reports']: 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( self.stdout.write(
"[{:%H:%M:%S}] Running {}...".format(timezone.now(), report.full_name) "[{:%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 # Report on success/failure
status = self.style.ERROR('FAILED') if report.failed else self.style.SUCCESS('SUCCESS') if job_result.status == JobResultStatusChoices.STATUS_FAILED:
for test_name, attrs in report.result.data.items(): 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( self.stdout.write(
"\t{}: {} success, {} info, {} warning, {} failure".format( "\t{}: {} success, {} info, {} warning, {} failure".format(
test_name, attrs['success'], attrs['info'], attrs['warning'], attrs['failure'] test_name, attrs['success'], attrs['info'], attrs['warning'], attrs['failure']
@@ -37,6 +60,9 @@ class Command(BaseCommand):
self.stdout.write( self.stdout.write(
"[{:%H:%M:%S}] {}: {}".format(timezone.now(), report.full_name, status) "[{:%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 # Wrap things up
self.stdout.write( self.stdout.write(

View File

@@ -200,14 +200,13 @@ class CustomField(models.Model):
# Select # Select
elif self.type == CustomFieldTypeChoices.TYPE_SELECT: elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
choices = [(cfc.pk, cfc.value) for cfc in self.choices.all()] 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) choices = add_blank_choice(choices)
# Set the initial value to the PK of the default choice, if any # Set the initial value to the PK of the default choice, if any
if set_initial: if set_initial and default_choice:
default_choice = self.choices.filter(value=self.default).first()
if default_choice:
initial = default_choice.pk initial = default_choice.pk
field_class = CSVChoiceField if for_csv_import else forms.ChoiceField field_class = CSVChoiceField if for_csv_import else forms.ChoiceField

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 # Compile all config data, overwriting lower-weight values with higher-weight values where a collision occurs
data = OrderedDict() 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 the object has local config context data defined, merge it last
if self.local_context_data: if self.local_context_data:

View File

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

View File

@@ -1,7 +1,8 @@
from collections import OrderedDict 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 from utilities.querysets import RestrictedQuerySet
@@ -23,9 +24,12 @@ class CustomFieldQueryset:
class ConfigContextQuerySet(RestrictedQuerySet): 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. 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 # `device_role` for Device; `role` for VirtualMachine
@@ -45,7 +49,7 @@ class ConfigContextQuerySet(RestrictedQuerySet):
else: else:
regions = [] regions = []
return self.filter( queryset = self.filter(
Q(regions__in=regions) | Q(regions=None), Q(regions__in=regions) | Q(regions=None),
Q(sites=obj.site) | Q(sites=None), Q(sites=obj.site) | Q(sites=None),
Q(roles=role) | Q(roles=None), Q(roles=role) | Q(roles=None),
@@ -57,3 +61,72 @@ class ConfigContextQuerySet(RestrictedQuerySet):
Q(tags__slug__in=obj.tags.slugs()) | Q(tags=None), Q(tags__slug__in=obj.tags.slugs()) | Q(tags=None),
is_active=True, is_active=True,
).order_by('weight', 'name') ).order_by('weight', 'name')
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")
)
)
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

@@ -35,10 +35,8 @@ OBJECTCHANGE_ACTION = """
""" """
OBJECTCHANGE_OBJECT = """ 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> <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 %} {% else %}
{{ record.object_repr }} {{ record.object_repr }}
{% endif %} {% endif %}

View File

@@ -20,8 +20,8 @@ GROUP_BUTTON = '<div class="btn-group">\n' \
GROUP_LINK = '<li><a href="{}"{}>{}</a></li>\n' GROUP_LINK = '<li><a href="{}"{}>{}</a></li>\n'
@register.simple_tag() @register.simple_tag(takes_context=True)
def custom_links(obj): def custom_links(context, obj):
""" """
Render all applicable links for the given object. Render all applicable links for the given object.
""" """
@@ -30,8 +30,13 @@ def custom_links(obj):
if not custom_links: if not custom_links:
return '' return ''
context = { # Pass select context data when rendering the CustomLink
link_context = {
'obj': obj, '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 = '' template_code = ''
group_names = OrderedDict() group_names = OrderedDict()
@@ -47,9 +52,9 @@ def custom_links(obj):
# Add non-grouped links # Add non-grouped links
else: else:
try: try:
text_rendered = render_jinja2(cl.text, context) text_rendered = render_jinja2(cl.text, link_context)
if text_rendered: 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 '' link_target = ' target="_blank"' if cl.new_window else ''
template_code += LINK_BUTTON.format( template_code += LINK_BUTTON.format(
link_rendered, link_target, cl.button_class, text_rendered link_rendered, link_target, cl.button_class, text_rendered
@@ -65,10 +70,10 @@ def custom_links(obj):
for cl in links: for cl in links:
try: try:
text_rendered = render_jinja2(cl.text, context) text_rendered = render_jinja2(cl.text, link_context)
if text_rendered: if text_rendered:
link_target = ' target="_blank"' if cl.new_window else '' 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( links_rendered.append(
GROUP_LINK.format(link_rendered, link_target, text_rendered) GROUP_LINK.format(link_rendered, link_target, text_rendered)
) )

View File

@@ -1,11 +1,11 @@
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.test import TestCase 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.choices import *
from extras.filters import * from extras.filters import *
from extras.utils import FeatureQuery 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 tenancy.models import Tenant, TenantGroup
from virtualization.models import Cluster, ClusterGroup, ClusterType from virtualization.models import Cluster, ClusterGroup, ClusterType
@@ -78,6 +78,84 @@ class ExportTemplateTestCase(TestCase):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) 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): class ConfigContextTestCase(TestCase):
queryset = ConfigContext.objects.all() queryset = ConfigContext.objects.all()
filterset = ConfigContextFilterSet filterset = ConfigContextFilterSet

View File

@@ -1,9 +1,11 @@
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.test import TestCase 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.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): class GraphTest(TestCase):
@@ -53,3 +55,276 @@ class TagTest(TestCase):
tag.save() tag.save()
self.assertEqual(tag.slug, 'testing-unicode-台灣') 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.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())

View File

@@ -59,3 +59,21 @@ class TaggedItemTest(APITestCase):
sorted([t.name for t in site.tags.all()]), sorted([t.name for t in site.tags.all()]),
sorted(["Foo", "Bar", "New Tag"]) 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 import uuid
from django.contrib.auth.models import User 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 django.urls import reverse
from dcim.models import Site from dcim.models import Site
from extras.choices import ObjectChangeActionChoices 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 from utilities.testing import ViewTestCases, TestCase
@@ -124,3 +126,24 @@ class ObjectChangeTestCase(TestCase):
objectchange = ObjectChange.objects.first() objectchange = ObjectChange.objects.first()
response = self.client.get(objectchange.get_absolute_url()) response = self.client.get(objectchange.get_absolute_url())
self.assertHttpStatus(response, 200) 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

@@ -1,5 +1,4 @@
from django import template from django import template
from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.models import Count, Prefetch, Q 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 dcim.models import DeviceRole, Platform, Region, Site
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from utilities.forms import ConfirmationForm 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.utils import copy_safe_request, shallow_compare_dict
from utilities.views import ( from utilities.views import (
BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView, BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
@@ -258,7 +257,7 @@ class ObjectChangeLogView(View):
# Apply the request context # Apply the request context
paginate = { paginate = {
'paginator_class': EnhancedPaginator, '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) 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. Retrieve all of the available reports from disk and the recorded JobResult (if any) for each.
""" """
def get_required_permission(self): def get_required_permission(self):
return 'extras.view_reportresult' return 'extras.view_report'
def get(self, request): def get(self, request):
@@ -347,7 +346,7 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
Display a single Report and its associated JobResult (if any). Display a single Report and its associated JobResult (if any).
""" """
def get_required_permission(self): def get_required_permission(self):
return 'extras.view_reportresult' return 'extras.view_report'
def get(self, request, module, name): 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) role = ChoiceField(choices=IPAddressRoleChoices, allow_blank=True, required=False)
assigned_object_type = ContentTypeField( assigned_object_type = ContentTypeField(
queryset=ContentType.objects.filter(IPADDRESS_ASSIGNMENT_MODELS), queryset=ContentType.objects.filter(IPADDRESS_ASSIGNMENT_MODELS),
required=False required=False,
allow_null=True
) )
assigned_object = serializers.SerializerMethodField(read_only=True) assigned_object = serializers.SerializerMethodField(read_only=True)
nat_inside = NestedIPAddressSerializer(required=False, allow_null=True) nat_inside = NestedIPAddressSerializer(required=False, allow_null=True)

View File

@@ -88,7 +88,7 @@ class PrefixViewSet(CustomFieldModelViewSet):
return super().get_serializer_class() return super().get_serializer_class()
@swagger_auto_schema(method='get', responses={200: serializers.AvailablePrefixSerializer(many=True)}) @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']) @action(detail=True, url_path='available-prefixes', methods=['get', 'post'])
@advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes']) @advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes'])
def available_prefixes(self, request, pk=None): def available_prefixes(self, request, pk=None):
@@ -247,7 +247,7 @@ class PrefixViewSet(CustomFieldModelViewSet):
class IPAddressViewSet(CustomFieldModelViewSet): class IPAddressViewSet(CustomFieldModelViewSet):
queryset = IPAddress.objects.prefetch_related( 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 serializer_class = serializers.IPAddressSerializer
filterset_class = filters.IPAddressFilterSet filterset_class = filters.IPAddressFilterSet

View File

@@ -641,11 +641,11 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
self.initial['primary_for_parent'] = True self.initial['primary_for_parent'] = True
def clean(self): def clean(self):
super().clean()
# Cannot select both a device interface and a VM interface # Cannot select both a device interface and a VM interface
if self.cleaned_data.get('interface') and self.cleaned_data.get('vminterface'): 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") 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. # Primary IP assignment is only available if an interface has been assigned.
interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface') interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface')
@@ -655,26 +655,21 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
) )
def save(self, *args, **kwargs): 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) ipaddress = super().save(*args, **kwargs)
# Assign/clear this IPAddress as the primary for the associated Device/VirtualMachine. # 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 interface and self.cleaned_data['primary_for_parent']:
if ipaddress.address.version == 4: if ipaddress.address.version == 4:
interface.parent.primary_ip4 = ipaddress interface.parent.primary_ip4 = ipaddress
else: else:
interface.primary_ip6 = ipaddress interface.parent.primary_ip6 = ipaddress
interface.parent.save() interface.parent.save()
elif interface and ipaddress.address.version == 4 and interface.parent.primary_ip4 == ipaddress: elif interface and ipaddress.address.version == 4 and interface.parent.primary_ip4 == ipaddress:
interface.parent.primary_ip4 = None interface.parent.primary_ip4 = None
interface.parent.save() interface.parent.save()
elif interface and ipaddress.address.version == 6 and interface.parent.primary_ip6 == ipaddress: 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() interface.parent.save()
return ipaddress return ipaddress

View File

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

View File

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

View File

@@ -90,7 +90,8 @@ urlpatterns = [
path('vlans/edit/', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'), path('vlans/edit/', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'),
path('vlans/delete/', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'), 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>/', 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>/edit/', views.VLANEditView.as_view(), name='vlan_edit'),
path('vlans/<int:pk>/delete/', views.VLANDeleteView.as_view(), name='vlan_delete'), 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}), 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 django_tables2 import RequestConfig
from dcim.models import Device, Interface 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.utils import get_subquery
from utilities.views import ( from utilities.views import (
BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView,
@@ -233,7 +233,7 @@ class AggregateView(ObjectView):
paginate = { paginate = {
'paginator_class': EnhancedPaginator, '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) RequestConfig(request, paginate).configure(prefix_table)
@@ -391,7 +391,7 @@ class PrefixPrefixesView(ObjectView):
paginate = { paginate = {
'paginator_class': EnhancedPaginator, '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) RequestConfig(request, paginate).configure(prefix_table)
@@ -435,7 +435,7 @@ class PrefixIPAddressesView(ObjectView):
paginate = { paginate = {
'paginator_class': EnhancedPaginator, '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) RequestConfig(request, paginate).configure(ip_table)
@@ -493,7 +493,7 @@ class PrefixBulkDeleteView(BulkDeleteView):
class IPAddressListView(ObjectListView): class IPAddressListView(ObjectListView):
queryset = IPAddress.objects.prefetch_related( queryset = IPAddress.objects.prefetch_related(
'vrf__tenant', 'tenant', 'nat_inside' 'vrf__tenant', 'tenant', 'nat_inside', 'assigned_object'
) )
filterset = filters.IPAddressFilterSet filterset = filters.IPAddressFilterSet
filterset_form = forms.IPAddressFilterForm filterset_form = forms.IPAddressFilterForm
@@ -527,7 +527,8 @@ class IPAddressView(ObjectView):
# Exclude anycast IPs if this IP is anycast # Exclude anycast IPs if this IP is anycast
if ipaddress.role == IPAddressRoleChoices.ROLE_ANYCAST: if ipaddress.role == IPAddressRoleChoices.ROLE_ANYCAST:
duplicate_ips = duplicate_ips.exclude(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 IP table
related_ips = IPAddress.objects.restrict(request.user, 'view').exclude( related_ips = IPAddress.objects.restrict(request.user, 'view').exclude(
@@ -539,7 +540,7 @@ class IPAddressView(ObjectView):
paginate = { paginate = {
'paginator_class': EnhancedPaginator, '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) RequestConfig(request, paginate).configure(related_ips_table)
@@ -547,6 +548,7 @@ class IPAddressView(ObjectView):
'ipaddress': ipaddress, 'ipaddress': ipaddress,
'parent_prefixes_table': parent_prefixes_table, 'parent_prefixes_table': parent_prefixes_table,
'duplicate_ips_table': duplicate_ips_table, 'duplicate_ips_table': duplicate_ips_table,
'more_duplicate_ips': duplicate_ips.count() > 10,
'related_ips_table': related_ips_table, 'related_ips_table': related_ips_table,
}) })
@@ -582,7 +584,7 @@ class IPAddressAssignView(ObjectView):
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
# Redirect user if an interface has not been provided # 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 redirect('ipam:ipaddress_add')
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
@@ -609,7 +611,7 @@ class IPAddressAssignView(ObjectView):
return render(request, 'ipam/ipaddress_assign.html', { return render(request, 'ipam/ipaddress_assign.html', {
'form': form, 'form': form,
'table': table, 'table': table,
'return_url': request.GET.get('return_url', ''), 'return_url': request.GET.get('return_url'),
}) })
@@ -699,7 +701,7 @@ class VLANGroupVLANsView(ObjectView):
paginate = { paginate = {
'paginator_class': EnhancedPaginator, '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) RequestConfig(request, paginate).configure(vlan_table)
@@ -713,6 +715,7 @@ class VLANGroupVLANsView(ObjectView):
return render(request, 'ipam/vlangroup_vlans.html', { return render(request, 'ipam/vlangroup_vlans.html', {
'vlan_group': vlan_group, 'vlan_group': vlan_group,
'first_available_vlan': vlan_group.get_next_available_vid(), 'first_available_vlan': vlan_group.get_next_available_vid(),
'bulk_querystring': 'group_id={}'.format(vlan_group.pk),
'vlan_table': vlan_table, 'vlan_table': vlan_table,
'permissions': permissions, 'permissions': permissions,
}) })
@@ -749,26 +752,45 @@ class VLANView(ObjectView):
}) })
class VLANMembersView(ObjectView): class VLANInterfacesView(ObjectView):
queryset = VLAN.objects.all() queryset = VLAN.objects.all()
def get(self, request, pk): def get(self, request, pk):
vlan = get_object_or_404(self.queryset, pk=pk) vlan = get_object_or_404(self.queryset, pk=pk)
members = vlan.get_members().restrict(request.user, 'view').prefetch_related('device', 'virtual_machine') interfaces = vlan.get_interfaces().prefetch_related('device')
members_table = tables.VLANDevicesTable(interfaces)
members_table = tables.VLANMemberTable(members)
paginate = { paginate = {
'paginator_class': EnhancedPaginator, '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) RequestConfig(request, paginate).configure(members_table)
return render(request, 'ipam/vlan_members.html', { return render(request, 'ipam/vlan_interfaces.html', {
'vlan': vlan, 'vlan': vlan,
'members_table': members_table, '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

@@ -172,9 +172,4 @@ class LDAPBackend:
if getattr(ldap_config, 'LDAP_IGNORE_CERT_ERRORS', False): if getattr(ldap_config, 'LDAP_IGNORE_CERT_ERRORS', False):
ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER) 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 return obj

View File

@@ -33,7 +33,6 @@ REDIS = {
# 'SENTINEL_SERVICE': 'netbox', # 'SENTINEL_SERVICE': 'netbox',
'PASSWORD': '', 'PASSWORD': '',
'DATABASE': 0, 'DATABASE': 0,
'DEFAULT_TIMEOUT': 300,
'SSL': False, 'SSL': False,
}, },
'caching': { 'caching': {
@@ -44,7 +43,6 @@ REDIS = {
# 'SENTINEL_SERVICE': 'netbox', # 'SENTINEL_SERVICE': 'netbox',
'PASSWORD': '', 'PASSWORD': '',
'DATABASE': 1, 'DATABASE': 1,
'DEFAULT_TIMEOUT': 300,
'SSL': False, 'SSL': False,
} }
} }
@@ -232,6 +230,9 @@ RELEASE_CHECK_URL = None
# this setting is derived from the installed location. # this setting is derived from the installed location.
# REPORTS_ROOT = '/opt/netbox/netbox/reports' # 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 # 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. # this setting is derived from the installed location.
# SCRIPTS_ROOT = '/opt/netbox/netbox/scripts' # SCRIPTS_ROOT = '/opt/netbox/netbox/scripts'

View File

@@ -24,7 +24,6 @@ REDIS = {
'PORT': 6379, 'PORT': 6379,
'PASSWORD': '', 'PASSWORD': '',
'DATABASE': 0, 'DATABASE': 0,
'DEFAULT_TIMEOUT': 300,
'SSL': False, 'SSL': False,
}, },
'caching': { 'caching': {
@@ -32,7 +31,6 @@ REDIS = {
'PORT': 6379, 'PORT': 6379,
'PASSWORD': '', 'PASSWORD': '',
'DATABASE': 1, 'DATABASE': 1,
'DEFAULT_TIMEOUT': 300,
'SSL': False, 'SSL': False,
} }
} }

View File

@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
# Environment setup # Environment setup
# #
VERSION = '2.9.1' VERSION = '2.9.8'
# Hostname # Hostname
HOSTNAME = platform.node() HOSTNAME = platform.node()
@@ -110,6 +110,7 @@ REMOTE_AUTH_HEADER = getattr(configuration, 'REMOTE_AUTH_HEADER', 'HTTP_REMOTE_U
RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None) RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None)
RELEASE_CHECK_TIMEOUT = getattr(configuration, 'RELEASE_CHECK_TIMEOUT', 24 * 3600) RELEASE_CHECK_TIMEOUT = getattr(configuration, 'RELEASE_CHECK_TIMEOUT', 24 * 3600)
REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/') 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('/') SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None) SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None)
SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d') SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d')
@@ -220,10 +221,13 @@ TASKS_REDIS_USING_SENTINEL = all([
len(TASKS_REDIS_SENTINELS) > 0 len(TASKS_REDIS_SENTINELS) > 0
]) ])
TASKS_REDIS_SENTINEL_SERVICE = TASKS_REDIS.get('SENTINEL_SERVICE', 'default') 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_PASSWORD = TASKS_REDIS.get('PASSWORD', '')
TASKS_REDIS_DATABASE = TASKS_REDIS.get('DATABASE', 0) 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) 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 # Caching
if 'caching' not in REDIS: if 'caching' not in REDIS:
@@ -241,7 +245,6 @@ CACHING_REDIS_USING_SENTINEL = all([
CACHING_REDIS_SENTINEL_SERVICE = CACHING_REDIS.get('SENTINEL_SERVICE', 'default') CACHING_REDIS_SENTINEL_SERVICE = CACHING_REDIS.get('SENTINEL_SERVICE', 'default')
CACHING_REDIS_PASSWORD = CACHING_REDIS.get('PASSWORD', '') CACHING_REDIS_PASSWORD = CACHING_REDIS.get('PASSWORD', '')
CACHING_REDIS_DATABASE = CACHING_REDIS.get('DATABASE', 0) 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) CACHING_REDIS_SSL = CACHING_REDIS.get('SSL', False)
@@ -549,7 +552,7 @@ if TASKS_REDIS_USING_SENTINEL:
'PASSWORD': TASKS_REDIS_PASSWORD, 'PASSWORD': TASKS_REDIS_PASSWORD,
'SOCKET_TIMEOUT': None, 'SOCKET_TIMEOUT': None,
'CONNECTION_KWARGS': { 'CONNECTION_KWARGS': {
'socket_connect_timeout': TASKS_REDIS_DEFAULT_TIMEOUT 'socket_connect_timeout': TASKS_REDIS_SENTINEL_TIMEOUT
}, },
} }
else: else:
@@ -558,8 +561,8 @@ else:
'PORT': TASKS_REDIS_PORT, 'PORT': TASKS_REDIS_PORT,
'DB': TASKS_REDIS_DATABASE, 'DB': TASKS_REDIS_DATABASE,
'PASSWORD': TASKS_REDIS_PASSWORD, 'PASSWORD': TASKS_REDIS_PASSWORD,
'DEFAULT_TIMEOUT': TASKS_REDIS_DEFAULT_TIMEOUT,
'SSL': TASKS_REDIS_SSL, 'SSL': TASKS_REDIS_SSL,
'DEFAULT_TIMEOUT': RQ_DEFAULT_TIMEOUT,
} }
RQ_QUEUES = { RQ_QUEUES = {

View File

@@ -36,7 +36,7 @@
Python version: {{ python_version }} Python version: {{ python_version }}
NetBox version: {{ netbox_version }}</pre> NetBox version: {{ netbox_version }}</pre>
<p> <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> </p>
<div class="text-right"> <div class="text-right">
<a href="{% url 'home' %}" class="btn btn-primary">Home Page</a> <a href="{% url 'home' %}" class="btn btn-primary">Home Page</a>

View File

@@ -11,11 +11,8 @@
<div class="row noprint"> <div class="row noprint">
<div class="col-sm-8 col-md-9"> <div class="col-sm-8 col-md-9">
<ol class="breadcrumb"> <ol class="breadcrumb">
<li><a href="{% url 'dcim:site' slug=device.site.slug %}">{{ device.site }}</a></li> <li><a href="{% url 'dcim:device_list' %}">Devices</a></li>
{% if device.rack %} <li><a href="{% url 'dcim:device_list' %}?site={{ device.site.slug }}">{{ device.site }}</a></li>
<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 %}
{% if device.parent_bay %} {% if device.parent_bay %}
<li><a href="{% url 'dcim:device' pk=device.parent_bay.device.pk %}">{{ device.parent_bay.device }}</a></li> <li><a href="{% url 'dcim:device' pk=device.parent_bay.device.pk %}">{{ device.parent_bay.device }}</a></li>
<li>{{ device.parent_bay }}</li> <li>{{ device.parent_bay }}</li>
@@ -51,28 +48,28 @@
</button> </button>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
{% if perms.dcim.add_consoleport %} {% 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 %} {% endif %}
{% if perms.dcim.add_consoleserverport %} {% 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 %} {% endif %}
{% if perms.dcim.add_powerport %} {% 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 %} {% endif %}
{% if perms.dcim.add_poweroutlet %} {% 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 %} {% endif %}
{% if perms.dcim.add_interface %} {% 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 %} {% endif %}
{% if perms.dcim.add_frontport %} {% 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 %} {% endif %}
{% if perms.dcim.add_rearport %} {% 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 %} {% endif %}
{% if perms.dcim.add_devicebay %} {% 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 %} {% endif %}
{% if perms.dcim.add_inventoryitem %} {% 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> <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>
<li role="presentation"{% if active_tab == 'inventory' %} class="active"{% endif %}> <li role="presentation"{% if active_tab == 'inventory' %} class="active"{% endif %}>
<a href="{% url 'dcim:device_inventory' pk=device.pk %}"> <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> </a>
</li> </li>
{% if perms.dcim.napalm_read_device %} {% if perms.dcim.napalm_read_device %}
@@ -151,8 +148,10 @@
<td> <td>
{% if device.rack %} {% if device.rack %}
{% if device.rack.group %} {% if device.rack.group %}
<a href="{{ device.rack.group.get_absolute_url }}">{{ device.rack.group }}</a> {% for group in device.rack.group.get_ancestors %}
<i class="fa fa-angle-right"></i> <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 %} {% endif %}
<a href="{% url 'dcim:rack' pk=device.rack.pk %}">{{ device.rack }}</a> <a href="{% url 'dcim:rack' pk=device.rack.pk %}">{{ device.rack }}</a>
{% else %} {% else %}
@@ -327,7 +326,7 @@
{% plugin_left_page device %} {% plugin_left_page device %}
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
{% if power_ports and poweroutlets %} {% if powerports and poweroutlets %}
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
<strong>Power Utilization</strong> <strong>Power Utilization</strong>
@@ -340,7 +339,7 @@
<th>Available</th> <th>Available</th>
<th>Utilization</th> <th>Utilization</th>
</tr> </tr>
{% for pp in power_ports %} {% for pp in powerports %}
{% with utilization=pp.get_power_draw powerfeed=pp.connected_endpoint %} {% with utilization=pp.get_power_draw powerfeed=pp.connected_endpoint %}
<tr> <tr>
<td>{{ pp }}</td> <td>{{ pp }}</td>
@@ -538,26 +537,26 @@
</table> </table>
<div class="panel-footer noprint"> <div class="panel-footer noprint">
{% if interfaces and perms.dcim.change_interface %} {% 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 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button> </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 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
</button> </button>
{% endif %} {% endif %}
{% if interfaces and perms.dcim.change_interface %} {% 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 <span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</button> </button>
{% endif %} {% endif %}
{% if interfaces and perms.dcim.delete_interface %} {% 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 <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button> </button>
{% endif %} {% endif %}
{% if perms.dcim.add_interface %} {% if perms.dcim.add_interface %}
<div class="pull-right"> <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 <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
</a> </a>
</div> </div>
@@ -598,24 +597,24 @@
</table> </table>
<div class="panel-footer noprint"> <div class="panel-footer noprint">
{% if frontports and perms.dcim.change_frontport %} {% 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 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button> </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 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
</button> </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 <span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</button> </button>
{% endif %} {% endif %}
{% if frontports and perms.dcim.delete_frontport %} {% 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 <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button> </button>
{% endif %} {% endif %}
{% if perms.dcim.add_frontport %} {% if perms.dcim.add_frontport %}
<div class="pull-right"> <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 <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add front ports
</a> </a>
</div> </div>
@@ -655,24 +654,24 @@
</table> </table>
<div class="panel-footer noprint"> <div class="panel-footer noprint">
{% if rearports and perms.dcim.change_rearport %} {% 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 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button> </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 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
</button> </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 <span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</button> </button>
{% endif %} {% endif %}
{% if rearports and perms.dcim.delete_rearport %} {% 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 <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button> </button>
{% endif %} {% endif %}
{% if perms.dcim.add_rearport %} {% if perms.dcim.add_rearport %}
<div class="pull-right"> <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 <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add rear ports
</a> </a>
</div> </div>
@@ -709,24 +708,24 @@
</table> </table>
<div class="panel-footer noprint"> <div class="panel-footer noprint">
{% if consoleports and perms.dcim.change_consoleport %} {% 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 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button> </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 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
</button> </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 <span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</button> </button>
{% endif %} {% endif %}
{% if consoleports and perms.dcim.delete_consoleport %} {% 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 <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button> </button>
{% endif %} {% endif %}
{% if perms.dcim.add_consoleport %} {% if perms.dcim.add_consoleport %}
<div class="pull-right"> <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 <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console port
</a> </a>
</div> </div>
@@ -764,24 +763,24 @@
</table> </table>
<div class="panel-footer noprint"> <div class="panel-footer noprint">
{% if consoleserverports and perms.dcim.change_consoleport %} {% 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 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button> </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 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
</button> </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 <span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</button> </button>
{% endif %} {% endif %}
{% if consoleserverports and perms.dcim.delete_consoleserverport %} {% 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 <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button> </button>
{% endif %} {% endif %}
{% if perms.dcim.add_consoleserverport %} {% if perms.dcim.add_consoleserverport %}
<div class="pull-right"> <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 <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console server ports
</a> </a>
</div> </div>
@@ -819,24 +818,24 @@
</table> </table>
<div class="panel-footer noprint"> <div class="panel-footer noprint">
{% if powerports and perms.dcim.change_powerport %} {% 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 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button> </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 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
</button> </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 <span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</button> </button>
{% endif %} {% endif %}
{% if powerports and perms.dcim.delete_powerport %} {% 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 <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button> </button>
{% endif %} {% endif %}
{% if perms.dcim.add_powerport %} {% if perms.dcim.add_powerport %}
<div class="pull-right"> <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 <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power port
</a> </a>
</div> </div>
@@ -875,24 +874,24 @@
</table> </table>
<div class="panel-footer noprint"> <div class="panel-footer noprint">
{% if poweroutlets and perms.dcim.change_powerport %} {% 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 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button> </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 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
</button> </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 <span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</button> </button>
{% endif %} {% endif %}
{% if poweroutlets and perms.dcim.delete_poweroutlet %} {% 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 <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button> </button>
{% endif %} {% endif %}
{% if perms.dcim.add_poweroutlet %} {% if perms.dcim.add_poweroutlet %}
<div class="pull-right"> <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 <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power outlets
</a> </a>
</div> </div>
@@ -934,18 +933,18 @@
</table> </table>
<div class="panel-footer noprint"> <div class="panel-footer noprint">
{% if devicebays and perms.dcim.change_devicebay %} {% 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 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button> </button>
{% endif %} {% endif %}
{% if devicebays and perms.dcim.delete_devicebay %} {% 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 <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete selected
</button> </button>
{% endif %} {% endif %}
{% if perms.dcim.add_devicebay %} {% if perms.dcim.add_devicebay %}
<div class="pull-right"> <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 <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add device bays
</a> </a>
</div> </div>
@@ -964,6 +963,16 @@
{% block javascript %} {% block javascript %}
<script type="text/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) { function toggleConnection(elem) {
var url = netbox_api_path + "dcim/cables/" + elem.attr('data') + "/"; var url = netbox_api_path + "dcim/cables/" + elem.attr('data') + "/";
if (elem.hasClass('connected')) { 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"> <div class="panel-body">
{% render_field form.region %} {% render_field form.region %}
{% render_field form.site %} {% render_field form.site %}
{% render_field form.rack_group %}
{% render_field form.rack %} {% render_field form.rack %}
{% if obj.device_type.is_child_device and obj.parent_bay %} {% if obj.device_type.is_child_device and obj.parent_bay %}
<div class="form-group"> <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> <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add Components <span class="caret"></span>
</button> </button>
<ul class="dropdown-menu"> <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_consoleporttemplate %}
{% 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 %} <li><a href="{% url 'dcim:consoleporttemplate_add' %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}%23tab_consoleports">Console Ports</a></li>
{% 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 %} {% 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_consoleserverporttemplate %}
{% 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 %} <li><a href="{% url 'dcim:consoleserverporttemplate_add' %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}%23tab_consoleserverports">Console Server Ports</a></li>
{% 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 %} {% 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_powerporttemplate %}
{% 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 %} <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> </ul>
</div> </div>
{% endif %} {% endif %}
@@ -217,3 +233,17 @@
</div> </div>
</div> </div>
{% endblock %} {% 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> </span>
{% endif %} {% endif %}
{% if perms.dcim.change_consoleport %} {% 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> <i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
</a> </a>
{% endif %} {% endif %}

View File

@@ -68,7 +68,7 @@
</span> </span>
{% endif %} {% endif %}
{% if perms.dcim.change_consoleserverport %} {% 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> <i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
</a> </a>
{% endif %} {% endif %}

View File

@@ -52,7 +52,7 @@
<i class="glyphicon glyphicon-plus" aria-hidden="true" title="Install device"></i> <i class="glyphicon glyphicon-plus" aria-hidden="true" title="Install device"></i>
</a> </a>
{% endif %} {% 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> <i class="glyphicon glyphicon-pencil" aria-hidden="true" title="Edit device bay"></i>
</a> </a>
{% endif %} {% endif %}

View File

@@ -81,7 +81,7 @@
</a> </a>
{% endif %} {% endif %}
{% if perms.dcim.change_poweroutlet %} {% 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> <i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
</a> </a>
{% endif %} {% endif %}

View File

@@ -78,7 +78,7 @@
</span> </span>
{% endif %} {% endif %}
{% if perms.dcim.change_powerport %} {% if perms.dcim.change_powerport %}
<a href="{% url 'dcim:powerport_edit' pk=pp.pk %}" title="Edit port" class="btn btn-info btn-xs"> <a href="{% url 'dcim:powerport_edit' pk=pp.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> <i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
</a> </a>
{% endif %} {% endif %}

View File

@@ -1,10 +0,0 @@
{% load helpers %}
<div class="rack_header">
<strong><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name }}</a></strong>
{% if rack.role %}
<br /><small class="label" style="color: {{ rack.role.color|fgcolor }}; background-color: #{{ rack.role.color }}">{{ rack.role }}</small>
{% endif %}
{% if rack.facility_id %}
<br /><small class="text-muted">{{ rack.facility_id }}</small>
{% endif %}
</div>

View File

@@ -5,6 +5,16 @@
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"><strong>Interface</strong></div> <div class="panel-heading"><strong>Interface</strong></div>
<div class="panel-body"> <div class="panel-body">
{% 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_field form.name %} {% render_field form.name %}
{% render_field form.label %} {% render_field form.label %}
{% render_field form.type %} {% render_field form.type %}
@@ -14,6 +24,11 @@
{% render_field form.mtu %} {% render_field form.mtu %}
{% render_field form.mgmt_only %} {% render_field form.mgmt_only %}
{% render_field form.description %} {% render_field form.description %}
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading"><strong>802.1Q Switching</strong></div>
<div class="panel-body">
{% render_field form.mode %} {% render_field form.mode %}
{% render_field form.untagged_vlan %} {% render_field form.untagged_vlan %}
{% render_field form.tagged_vlans %} {% render_field form.tagged_vlans %}

View File

@@ -11,6 +11,12 @@
<ol class="breadcrumb"> <ol class="breadcrumb">
<li><a href="{% url 'dcim:rack_list' %}">Racks</a></li> <li><a href="{% url 'dcim:rack_list' %}">Racks</a></li>
<li><a href="{% url 'dcim:rack_list' %}?site={{ rack.site.slug }}">{{ rack.site }}</a></li> <li><a href="{% url 'dcim:rack_list' %}?site={{ rack.site.slug }}">{{ rack.site }}</a></li>
{% if rack.group %}
{% for group in rack.group.get_ancestors %}
<li><a href="{{ group.get_absolute_url }}">{{ group }}</a></li>
{% endfor %}
<li><a href="{{ rack.group.get_absolute_url }}">{{ rack.group }}</a></li>
{% endif %}
<li>{{ rack }}</li> <li>{{ rack }}</li>
</ol> </ol>
</div> </div>
@@ -87,7 +93,10 @@
<td>Group</td> <td>Group</td>
<td> <td>
{% if rack.group %} {% if rack.group %}
<a href="{% url 'dcim:rack_list' %}?site={{ rack.site.slug }}&group={{ rack.group.slug }}">{{ rack.group }}</a> {% for group in rack.group.get_ancestors %}
<a href="{{ group.get_absolute_url }}">{{ group }}</a> <i class="fa fa-caret-right"></i>
{% endfor %}
<a href="{{ rack.group.get_absolute_url }}">{{ rack.group }}</a>
{% else %} {% else %}
<span class="text-muted">None</span> <span class="text-muted">None</span>
{% endif %} {% endif %}

View File

@@ -3,12 +3,18 @@
{% load static %} {% load static %}
{% block content %} {% block content %}
<div class="btn-group pull-right noprint" role="group"> <div class="btn-toolbar pull-right noprint" role="toolbar">
<button class="btn btn-default toggle-images" selected="selected"> <button class="btn btn-default toggle-images" selected="selected">
<span class="glyphicon glyphicon-check" aria-hidden="true"></span> Show Images <span class="glyphicon glyphicon-check" aria-hidden="true"></span> Show Images
</button> </button>
<div class="btn-group" role="group">
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='front' %}" class="btn btn-default{% if rack_face == 'front' %} active{% endif %}">Front</a> <a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='front' %}" class="btn btn-default{% if rack_face == 'front' %} active{% endif %}">Front</a>
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='rear' %}" class="btn btn-default{% if rack_face == 'rear' %} active{% endif %}">Rear</a> <a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='rear' %}" class="btn btn-default{% if rack_face == 'rear' %} active{% endif %}">Rear</a>
</div>
<div class="btn-group" role="group">
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request reverse=None %}" class="btn btn-default{% if not reverse %} active{% endif %}">Normal</a>
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request reverse='true' %}" class="btn btn-default{% if reverse %} active{% endif %}">Reversed</a>
</div>
</div> </div>
<h1>{% block title %}Rack Elevations{% endblock %}</h1> <h1>{% block title %}Rack Elevations{% endblock %}</h1>
<div class="row"> <div class="row">
@@ -17,10 +23,23 @@
<div style="white-space: nowrap; overflow-x: scroll;"> <div style="white-space: nowrap; overflow-x: scroll;">
{% for rack in page %} {% for rack in page %}
<div style="display: inline-block; width: 266px"> <div style="display: inline-block; width: 266px">
{% include 'dcim/inc/rack_elevation_header.html' %} <div class="text-center">
<strong><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name }}</a></strong>
{% if rack.role %}
<br /><small class="label" style="color: {{ rack.role.color|fgcolor }}; background-color: #{{ rack.role.color }}">{{ rack.role }}</small>
{% endif %}
{% if rack.facility_id %}
<br /><small class="text-muted">{{ rack.facility_id }}</small>
{% endif %}
</div>
{% include 'dcim/inc/rack_elevation.html' with face=rack_face %} {% include 'dcim/inc/rack_elevation.html' with face=rack_face %}
<div class="clearfix"></div> <div class="clearfix"></div>
{% include 'dcim/inc/rack_elevation_header.html' %} <div class="text-center">
<strong><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name }}</a></strong>
{% if rack.facility_id %}
<small class="text-muted">({{ rack.facility_id }})</small>
{% endif %}
</div>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>

View File

@@ -12,7 +12,7 @@
<ol class="breadcrumb"> <ol class="breadcrumb">
<li><a href="{% url 'dcim:site_list' %}">Sites</a></li> <li><a href="{% url 'dcim:site_list' %}">Sites</a></li>
{% if site.region %} {% if site.region %}
{% for region in site.region.get_ancestors.unrestricted %} {% for region in site.region.get_ancestors %}
<li><a href="{{ region.get_absolute_url }}">{{ region }}</a></li> <li><a href="{{ region.get_absolute_url }}">{{ region }}</a></li>
{% endfor %} {% endfor %}
<li><a href="{{ site.region.get_absolute_url }}">{{ site.region }}</a></li> <li><a href="{{ site.region.get_absolute_url }}">{{ site.region }}</a></li>
@@ -86,7 +86,7 @@
<td>Region</td> <td>Region</td>
<td> <td>
{% if site.region %} {% if site.region %}
{% for region in site.region.get_ancestors.unrestricted %} {% for region in site.region.get_ancestors %}
<a href="{{ region.get_absolute_url }}">{{ region }}</a> <a href="{{ region.get_absolute_url }}">{{ region }}</a>
<i class="fa fa-angle-right"></i> <i class="fa fa-angle-right"></i>
{% endfor %} {% endfor %}
@@ -255,7 +255,7 @@
<table class="table table-hover panel-body"> <table class="table table-hover panel-body">
{% for rg in rack_groups %} {% for rg in rack_groups %}
<tr> <tr>
<td><i class="fa fa-fw fa-folder-o"></i> <a href="{{ rg.get_absolute_url }}">{{ rg }}</a></td> <td style="padding-left: {{ rg.level }}8px"><i class="fa fa-fw fa-folder-o"></i> <a href="{{ rg.get_absolute_url }}">{{ rg }}</a></td>
<td>{{ rg.rack_count }}</td> <td>{{ rg.rack_count }}</td>
<td class="text-right noprint"> <td class="text-right noprint">
<a href="{% url 'dcim:rack_elevation_list' %}?group_id={{ rg.pk }}" class="btn btn-xs btn-primary" title="View elevations"> <a href="{% url 'dcim:rack_elevation_list' %}?group_id={{ rg.pk }}" class="btn btn-xs btn-primary" title="View elevations">

View File

@@ -5,14 +5,15 @@
A module import error occurred during this request. Common causes include the following: A module import error occurred during this request. Common causes include the following:
</p> </p>
<p> <p>
<i class="fa fa-warning"></i> <strong>Missing required packages</strong> - This installation of NetBox might be missing one or more required <i class="fa fa-warning"></i> <strong>Missing required packages</strong> - This installation of NetBox might be
Python packages. These packages are listed in <code>requirements.txt</code> and are normally installed as part missing one or more required Python packages. These packages are listed in <code>requirements.txt</code> and
of the installation or upgrade process. To verify installed packages, run <code>pip freeze</code> from the <code>local_requirements.txt</code>, and are normally installed as part of the installation or upgrade process.
console and compare the output to the list of required packages. To verify installed packages, run <code>pip freeze</code> from the console and compare the output to the list of
required packages.
</p> </p>
<p> <p>
<i class="fa fa-warning"></i> <strong>WSGI service not restarted after upgrade</strong> - If this installation has recently been upgraded, <i class="fa fa-warning"></i> <strong>WSGI service not restarted after upgrade</strong> - If this installation
check that the WSGI service (e.g. gunicorn or uWSGI) has been restarted. This ensures that the new code is has recently been upgraded, check that the WSGI service (e.g. gunicorn or uWSGI) has been restarted. This
running. ensures that the new code is running.
</p> </p>
{% endblock %} {% endblock %}

View File

@@ -276,7 +276,7 @@
<div class="panel-heading"> <div class="panel-heading">
<strong>Reports</strong> <strong>Reports</strong>
</div> </div>
{% if report_results and perms.extras.view_reportresult %} {% if report_results and perms.extras.view_report %}
<table class="table table-hover panel-body"> <table class="table table-hover panel-body">
{% for result in report_results %} {% for result in report_results %}
<tr> <tr>
@@ -285,7 +285,7 @@
</tr> </tr>
{% endfor %} {% endfor %}
</table> </table>
{% elif perms.extras.view_reportresult %} {% elif perms.extras.view_report %}
<div class="panel-body text-muted"> <div class="panel-body text-muted">
None found None found
</div> </div>

View File

@@ -518,7 +518,7 @@
<li{% if not perms.extras.view_script %} class="disabled"{% endif %}> <li{% if not perms.extras.view_script %} class="disabled"{% endif %}>
<a href="{% url 'extras:script_list' %}">Scripts</a> <a href="{% url 'extras:script_list' %}">Scripts</a>
</li> </li>
<li{% if not perms.extras.view_reportresult %} class="disabled"{% endif %}> <li{% if not perms.extras.view_report %} class="disabled"{% endif %}>
<a href="{% url 'extras:report_list' %}">Reports</a> <a href="{% url 'extras:report_list' %}">Reports</a>
</li> </li>
</ul> </ul>

View File

@@ -5,7 +5,8 @@
{% for section_name, menu_items in registry.plugin_menu_items.items %} {% for section_name, menu_items in registry.plugin_menu_items.items %}
<li class="dropdown-header">{{ section_name }}</li> <li class="dropdown-header">{{ section_name }}</li>
{% for menu_item in menu_items %} {% for menu_item in menu_items %}
<li{% if menu_item.permissions and not request.user|has_perms:menu_item.permissions %} class="disabled"{% endif %}> {% if not menu_item.permissions or request.user|has_perms:menu_item.permissions %}
<li>
{% if menu_item.buttons %} {% if menu_item.buttons %}
<div class="buttons pull-right"> <div class="buttons pull-right">
{% for button in menu_item.buttons %} {% for button in menu_item.buttons %}
@@ -17,6 +18,9 @@
{% endif %} {% endif %}
<a href="{% url menu_item.link %}">{{ menu_item.link_text }}</a> <a href="{% url menu_item.link %}">{{ menu_item.link_text }}</a>
</li> </li>
{% else %}
<li class="disabled"><a href="#">{{ menu_item.link_text }}</a></li>
{% endif %}
{% endfor %} {% endfor %}
{% if not forloop.last %} {% if not forloop.last %}
<li class="divider"></li> <li class="divider"></li>

View File

@@ -4,7 +4,7 @@
<li role="presentation"{% if active_tab == 'add' %} class="active"{% endif %}> <li role="presentation"{% if active_tab == 'add' %} class="active"{% endif %}>
<a href="{% url 'ipam:ipaddress_add' %}{% querystring request %}">New IP</a> <a href="{% url 'ipam:ipaddress_add' %}{% querystring request %}">New IP</a>
</li> </li>
{% if 'interface' in request.GET %} {% if 'interface' in request.GET or 'vminterface' in request.GET %}
<li role="presentation"{% if active_tab == 'assign' %} class="active"{% endif %}> <li role="presentation"{% if active_tab == 'assign' %} class="active"{% endif %}>
<a href="{% url 'ipam:ipaddress_assign' %}{% querystring request %}">Assign IP</a> <a href="{% url 'ipam:ipaddress_assign' %}{% querystring request %}">Assign IP</a>
</li> </li>

View File

@@ -3,6 +3,7 @@
{% load custom_links %} {% load custom_links %}
{% load helpers %} {% load helpers %}
{% load plugins %} {% load plugins %}
{% load render_table from django_tables2 %}
{% block header %} {% block header %}
<div class="row noprint"> <div class="row noprint">
@@ -159,7 +160,24 @@
<div class="col-md-8"> <div class="col-md-8">
{% include 'panel_table.html' with table=parent_prefixes_table heading='Parent Prefixes' %} {% include 'panel_table.html' with table=parent_prefixes_table heading='Parent Prefixes' %}
{% if duplicate_ips_table.rows %} {% if duplicate_ips_table.rows %}
{% include 'panel_table.html' with table=duplicate_ips_table heading='Duplicate IP Addresses' panel_class='danger' %} {# Custom version of panel_table.html #}
<div class="panel panel-danger">
<div class="panel-heading">
<strong>Duplicate IP Addresses</strong>
{% if more_duplicate_ips %}
<div class="pull-right">
<a type="button" class="btn btn-primary btn-xs"
{% if ipaddress.vrf %}
href="{% url 'ipam:ipaddress_list' %}?address={{ ipaddress.address.ip }}&vrf_id={{ ipaddress.vrf.pk }}"
{% else %}
href="{% url 'ipam:ipaddress_list' %}?address={{ ipaddress.address.ip }}&vrf_id=null"
{% endif %}
>Show all</a>
</div>
{% endif %}
</div>
{% render_table duplicate_ips_table 'inc/table.html' %}
</div>
{% endif %} {% endif %}
{% include 'utilities/obj_table.html' with table=related_ips_table table_template='panel_table.html' heading='Related IP Addresses' panel_class='default noprint' %} {% include 'utilities/obj_table.html' with table=related_ips_table table_template='panel_table.html' heading='Related IP Addresses' panel_class='default noprint' %}
{% plugin_right_page ipaddress %} {% plugin_right_page ipaddress %}

View File

@@ -52,8 +52,11 @@
<li role="presentation"{% if not active_tab %} class="active"{% endif %}> <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
<a href="{% url 'ipam:vlan' pk=vlan.pk %}">VLAN</a> <a href="{% url 'ipam:vlan' pk=vlan.pk %}">VLAN</a>
</li> </li>
<li role="presentation"{% if active_tab == 'members' %} class="active"{% endif %}> <li role="presentation"{% if active_tab == 'interfaces' %} class="active"{% endif %}>
<a href="{% url 'ipam:vlan_members' pk=vlan.pk %}">Members <span class="badge">{{ vlan.get_members.count }}</span></a> <a href="{% url 'ipam:vlan_interfaces' pk=vlan.pk %}">Device Interfaces <span class="badge">{{ vlan.get_interfaces.count }}</span></a>
</li>
<li role="presentation"{% if active_tab == 'vminterfaces' %} class="active"{% endif %}>
<a href="{% url 'ipam:vlan_vminterfaces' pk=vlan.pk %}">VM Interfaces <span class="badge">{{ vlan.get_vminterfaces.count }}</span></a>
</li> </li>
{% if perms.extras.view_objectchange %} {% if perms.extras.view_objectchange %}
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}> <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>

View File

@@ -1,11 +1,9 @@
{% extends 'ipam/vlan.html' %} {% extends 'ipam/vlan.html' %}
{% block title %}{{ block.super }} - Members{% endblock %}
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-12">
{% include 'utilities/obj_table.html' with table=members_table table_template='panel_table.html' heading='VLAN Members' parent=vlan %} {% include 'utilities/obj_table.html' with table=members_table table_template='panel_table.html' heading='Device Interfaces' parent=vlan %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,9 @@
{% extends 'ipam/vlan.html' %}
{% block content %}
<div class="row">
<div class="col-md-12">
{% include 'utilities/obj_table.html' with table=members_table table_template='panel_table.html' heading='Virtual Machine Interfaces' parent=vlan %}
</div>
</div>
{% endblock %}

View File

@@ -31,7 +31,9 @@
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"><strong>{{ obj_type|capfirst }}</strong></div> <div class="panel-heading"><strong>{{ obj_type|capfirst }}</strong></div>
<div class="panel-body"> <div class="panel-body">
{% block form_fields %}
{% render_form form %} {% render_form form %}
{% endblock %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -2,7 +2,7 @@
<tr class="interface{% if not iface.enabled %} danger{% endif %}" id="interface_{{ iface.name }}"> <tr class="interface{% if not iface.enabled %} danger{% endif %}" id="interface_{{ iface.name }}">
{# Checkbox #} {# Checkbox #}
{% if perms.virtualization.change_interface or perms.virtualization.delete_interface %} {% if perms.virtualization.change_vminterface or perms.virtualization.delete_vminterface %}
<td class="pk"> <td class="pk">
<input name="pk" type="checkbox" value="{{ iface.pk }}" /> <input name="pk" type="checkbox" value="{{ iface.pk }}" />
</td> </td>
@@ -48,12 +48,12 @@
<i class="glyphicon glyphicon-plus" aria-hidden="true"></i> <i class="glyphicon glyphicon-plus" aria-hidden="true"></i>
</a> </a>
{% endif %} {% endif %}
{% if perms.virtualization.change_interface %} {% if perms.virtualization.change_vminterface %}
<a href="{% url 'virtualization:vminterface_edit' pk=iface.pk %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-info btn-xs" title="Edit interface"> <a href="{% url 'virtualization:vminterface_edit' pk=iface.pk %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-info btn-xs" title="Edit interface">
<i class="glyphicon glyphicon-pencil" aria-hidden="true"></i> <i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
</a> </a>
{% endif %} {% endif %}
{% if perms.virtualization.delete_interface %} {% if perms.virtualization.delete_vminterface %}
<a href="{% url 'virtualization:vminterface_delete' pk=iface.pk %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-danger btn-xs" title="Delete interface"> <a href="{% url 'virtualization:vminterface_delete' pk=iface.pk %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-danger btn-xs" title="Delete interface">
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i> <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</a> </a>
@@ -65,7 +65,7 @@
{% if ipaddresses %} {% if ipaddresses %}
<tr class="ipaddresses"> <tr class="ipaddresses">
{# Placeholder #} {# Placeholder #}
{% if perms.virtualization.change_interface or perms.virtualization.delete_interface %} {% if perms.virtualization.change_vminterface or perms.virtualization.delete_vminterface %}
<td></td> <td></td>
{% endif %} {% endif %}

View File

@@ -2,7 +2,7 @@
{% load helpers %} {% load helpers %}
{% load form_helpers %} {% load form_helpers %}
{% block title %}Create {{ component_type }} ({{ parent }}){% endblock %} {% block title %}Create {{ component_type }}{% endblock %}
{% block content %} {% block content %}
<form action="" method="post" class="form form-horizontal"> <form action="" method="post" class="form form-horizontal">

View File

@@ -5,14 +5,34 @@
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"><strong>Interface</strong></div> <div class="panel-heading"><strong>Interface</strong></div>
<div class="panel-body"> <div class="panel-body">
{% if form.instance.virtual_machine %}
<div class="form-group">
<label class="col-md-3 control-label required" for="id_device">Virtual Machine</label>
<div class="col-md-9">
<p class="form-control-static">
<a href="{{ form.instance.virtual_machine.get_absolute_url }}">{{ form.instance.virtual_machine }}</a>
</p>
</div>
</div>
{% endif %}
{% render_field form.name %} {% render_field form.name %}
{% render_field form.enabled %} {% render_field form.enabled %}
{% render_field form.mac_address %} {% render_field form.mac_address %}
{% render_field form.mtu %} {% render_field form.mtu %}
{% render_field form.description %} {% render_field form.description %}
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading"><strong>802.1Q Switching</strong></div>
<div class="panel-body">
{% render_field form.mode %} {% render_field form.mode %}
{% render_field form.untagged_vlan %} {% render_field form.untagged_vlan %}
{% render_field form.tagged_vlans %} {% render_field form.tagged_vlans %}
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Tags</strong></div>
<div class="panel-body">
{% render_field form.tags %} {% render_field form.tags %}
</div> </div>
</div> </div>

View File

@@ -22,7 +22,7 @@ class ObjectPermissionInline(admin.TabularInline):
verbose_name_plural = 'Permissions' verbose_name_plural = 'Permissions'
def get_queryset(self, request): def get_queryset(self, request):
return super().get_queryset(request).prefetch_related('objectpermission__object_types') return super().get_queryset(request).prefetch_related('objectpermission__object_types').nocache()
@staticmethod @staticmethod
def object_types(instance): def object_types(instance):
@@ -185,7 +185,7 @@ class ObjectPermissionForm(forms.ModelForm):
# Validate the specified model constraints by attempting to execute a query. We don't care whether the query # Validate the specified model constraints by attempting to execute a query. We don't care whether the query
# returns anything; we just want to make sure the specified constraints are valid. # returns anything; we just want to make sure the specified constraints are valid.
if constraints: if object_types and constraints:
# Normalize the constraints to a list of dicts # Normalize the constraints to a list of dicts
if type(constraints) is not list: if type(constraints) is not list:
constraints = [constraints] constraints = [constraints]

View File

@@ -38,6 +38,10 @@ class LoginView(View):
def get(self, request): def get(self, request):
form = LoginForm(request) form = LoginForm(request)
if request.user.is_authenticated:
logger = logging.getLogger('netbox.auth.login')
return self.redirect_to_next(request, logger)
return render(request, self.template_name, { return render(request, self.template_name, {
'form': form, 'form': form,
}) })
@@ -49,12 +53,6 @@ class LoginView(View):
if form.is_valid(): if form.is_valid():
logger.debug("Login form validation was successful") logger.debug("Login form validation was successful")
# Determine where to direct user after successful login
redirect_to = request.POST.get('next', reverse('home'))
if redirect_to and not is_safe_url(url=redirect_to, allowed_hosts=request.get_host()):
logger.warning(f"Ignoring unsafe 'next' URL passed to login form: {redirect_to}")
redirect_to = reverse('home')
# If maintenance mode is enabled, assume the database is read-only, and disable updating the user's # If maintenance mode is enabled, assume the database is read-only, and disable updating the user's
# last_login time upon authentication. # last_login time upon authentication.
if settings.MAINTENANCE_MODE: if settings.MAINTENANCE_MODE:
@@ -66,8 +64,7 @@ class LoginView(View):
logger.info(f"User {request.user} successfully authenticated") logger.info(f"User {request.user} successfully authenticated")
messages.info(request, "Logged in as {}.".format(request.user)) messages.info(request, "Logged in as {}.".format(request.user))
logger.debug(f"Redirecting user to {redirect_to}") return self.redirect_to_next(request, logger)
return HttpResponseRedirect(redirect_to)
else: else:
logger.debug("Login form validation failed") logger.debug("Login form validation failed")
@@ -76,6 +73,19 @@ class LoginView(View):
'form': form, 'form': form,
}) })
def redirect_to_next(self, request, logger):
if request.method == "POST":
redirect_to = request.POST.get('next', reverse('home'))
else:
redirect_to = request.GET.get('next', reverse('home'))
if redirect_to and not is_safe_url(url=redirect_to, allowed_hosts=request.get_host()):
logger.warning(f"Ignoring unsafe 'next' URL passed to login form: {redirect_to}")
redirect_to = reverse('home')
logger.debug(f"Redirecting user to {redirect_to}")
return HttpResponseRedirect(redirect_to)
class LogoutView(View): class LogoutView(View):
""" """

View File

@@ -141,7 +141,7 @@ class APISelect(SelectWithDisabled):
key = f'data-query-param-{name}' key = f'data-query-param-{name}'
values = json.loads(self.attrs.get(key, '[]')) values = json.loads(self.attrs.get(key, '[]'))
if type(value) is list: if type(value) in (list, tuple):
values.extend([str(v) for v in value]) values.extend([str(v) for v in value])
else: else:
values.append(str(value)) values.append(str(value))

View File

@@ -22,7 +22,7 @@ class Command(_Command):
"This command is available for development purposes only. It will\n" "This command is available for development purposes only. It will\n"
"NOT resolve any issues with missing or unapplied migrations. For assistance,\n" "NOT resolve any issues with missing or unapplied migrations. For assistance,\n"
"please post to the NetBox mailing list:\n" "please post to the NetBox mailing list:\n"
" https://groups.google.com/forum/#!forum/netbox-discuss" " https://groups.google.com/g/netbox-discuss"
) )
super().handle(*args, **kwargs) super().handle(*args, **kwargs)

View File

@@ -1,3 +1,5 @@
from django.contrib.postgres.aggregates import JSONBAgg
from django.contrib.postgres.aggregates.mixins import OrderableAggMixin
from django.db.models import F, Func from django.db.models import F, Func
@@ -7,3 +9,21 @@ class CollateAsChar(Func):
""" """
function = 'C' function = 'C'
template = '(%(expressions)s) COLLATE "%(function)s"' template = '(%(expressions)s) COLLATE "%(function)s"'
class OrderableJSONBAgg(OrderableAggMixin, JSONBAgg):
"""
TODO in Django 3.2 ordering is supported natively on JSONBAgg so this is no longer needed.
"""
template = '%(function)s(%(distinct)s%(expressions)s %(ordering)s)'
class EmptyGroupByJSONBAgg(OrderableJSONBAgg):
"""
JSONBAgg is a builtin aggregation function which means it includes the use of a GROUP BY clause.
When used as an annotation for collecting config context data objects, the GROUP BY is
incorrect. This subclass overrides the Django ORM aggregation control to remove the GROUP BY.
TODO in Django 3.2 ordering is supported natively on JSONBAgg so we only need to inherit from JSONBAgg.
"""
contains_aggregate = False

View File

@@ -1,4 +1,5 @@
import django_tables2 as tables import django_tables2 as tables
from django.contrib.contenttypes.fields import GenericForeignKey
from django.core.exceptions import FieldDoesNotExist from django.core.exceptions import FieldDoesNotExist
from django.db.models.fields.related import RelatedField from django.db.models.fields.related import RelatedField
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
@@ -44,7 +45,7 @@ class BaseTable(tables.Table):
self.columns.show(name) self.columns.show(name)
else: else:
self.columns.hide(name) self.columns.hide(name)
self.sequence = columns self.sequence = [c for c in columns if c in self.base_columns]
# Always include PK and actions column, if defined on the table # Always include PK and actions column, if defined on the table
if pk: if pk:
@@ -63,7 +64,7 @@ class BaseTable(tables.Table):
field_path = column.accessor.split('.') field_path = column.accessor.split('.')
try: try:
model_field = model._meta.get_field(field_path[0]) model_field = model._meta.get_field(field_path[0])
if isinstance(model_field, RelatedField): if isinstance(model_field, (RelatedField, GenericForeignKey)):
prefetch_fields.append('__'.join(field_path)) prefetch_fields.append('__'.join(field_path))
except FieldDoesNotExist: except FieldDoesNotExist:
pass pass
@@ -114,12 +115,12 @@ class BooleanColumn(tables.Column):
character. character.
""" """
def render(self, value): def render(self, value):
if value is True: if value:
rendered = '<span class="text-success"><i class="fa fa-check"></i></span>' rendered = '<span class="text-success"><i class="fa fa-check"></i></span>'
elif value is False: elif value is None:
rendered = '<span class="text-danger"><i class="fa fa-close"></i></span>'
else:
rendered = '<span class="text-muted">&mdash;</span>' rendered = '<span class="text-muted">&mdash;</span>'
else:
rendered = '<span class="text-danger"><i class="fa fa-close"></i></span>'
return mark_safe(rendered) return mark_safe(rendered)
@@ -129,6 +130,7 @@ class ButtonsColumn(tables.TemplateColumn):
:param model: Model class to use for calculating URL view names :param model: Model class to use for calculating URL view names
:param prepend_content: Additional template content to render in the column (optional) :param prepend_content: Additional template content to render in the column (optional)
:param return_url_extra: String to append to the return URL (e.g. for specifying a tab) (optional)
""" """
buttons = ('changelog', 'edit', 'delete') buttons = ('changelog', 'edit', 'delete')
attrs = {'td': {'class': 'text-right text-nowrap noprint'}} attrs = {'td': {'class': 'text-right text-nowrap noprint'}}
@@ -140,18 +142,19 @@ class ButtonsColumn(tables.TemplateColumn):
</a> </a>
{{% endif %}} {{% endif %}}
{{% if "edit" in buttons and perms.{app_label}.change_{model_name} %}} {{% if "edit" in buttons and perms.{app_label}.change_{model_name} %}}
<a href="{{% url '{app_label}:{model_name}_edit' {pk_field}=record.{pk_field} %}}?return_url={{{{ request.path }}}}" class="btn btn-xs btn-warning" title="Edit"> <a href="{{% url '{app_label}:{model_name}_edit' {pk_field}=record.{pk_field} %}}?return_url={{{{ request.path }}}}{{{{ return_url_extra }}}}" class="btn btn-xs btn-warning" title="Edit">
<i class="fa fa-pencil"></i> <i class="fa fa-pencil"></i>
</a> </a>
{{% endif %}} {{% endif %}}
{{% if "delete" in buttons and perms.{app_label}.delete_{model_name} %}} {{% if "delete" in buttons and perms.{app_label}.delete_{model_name} %}}
<a href="{{% url '{app_label}:{model_name}_delete' {pk_field}=record.{pk_field} %}}?return_url={{{{ request.path }}}}" class="btn btn-xs btn-danger" title="Delete"> <a href="{{% url '{app_label}:{model_name}_delete' {pk_field}=record.{pk_field} %}}?return_url={{{{ request.path }}}}{{{{ return_url_extra }}}}" class="btn btn-xs btn-danger" title="Delete">
<i class="fa fa-trash"></i> <i class="fa fa-trash"></i>
</a> </a>
{{% endif %}} {{% endif %}}
""" """
def __init__(self, model, *args, pk_field='pk', buttons=None, prepend_template=None, **kwargs): def __init__(self, model, *args, pk_field='pk', buttons=None, prepend_template=None, return_url_extra='',
**kwargs):
if prepend_template: if prepend_template:
prepend_template = prepend_template.replace('{', '{{') prepend_template = prepend_template.replace('{', '{{')
prepend_template = prepend_template.replace('}', '}}') prepend_template = prepend_template.replace('}', '}}')
@@ -168,6 +171,7 @@ class ButtonsColumn(tables.TemplateColumn):
self.extra_context.update({ self.extra_context.update({
'buttons': buttons or self.buttons, 'buttons': buttons or self.buttons,
'return_url_extra': return_url_extra,
}) })
def header(self): def header(self):

View File

@@ -266,7 +266,7 @@ class APIViewTestCases:
response = self.client.patch(url, update_data, format='json', **self.header) response = self.client.patch(url, update_data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK) self.assertHttpStatus(response, status.HTTP_200_OK)
instance.refresh_from_db() instance.refresh_from_db()
self.assertInstanceEqual(instance, self.update_data, api=True) self.assertInstanceEqual(instance, update_data, api=True)
class DeleteObjectViewTestCase(APITestCase): class DeleteObjectViewTestCase(APITestCase):

View File

@@ -945,7 +945,7 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
# ManyToManyFields # ManyToManyFields
elif isinstance(model_field, ManyToManyField): elif isinstance(model_field, ManyToManyField):
if form.cleaned_data[name].count() > 0: if form.cleaned_data[name]:
getattr(obj, name).set(form.cleaned_data[name]) getattr(obj, name).set(form.cleaned_data[name])
# Normal fields # Normal fields
elif form.cleaned_data[name] not in (None, ''): elif form.cleaned_data[name] not in (None, ''):
@@ -1352,7 +1352,7 @@ class BulkComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin,
for obj in data['pk']: for obj in data['pk']:
names = data['name_pattern'] names = data['name_pattern']
labels = data['label_pattern'] labels = data['label_pattern'] if 'label_pattern' in data else None
for i, name in enumerate(names): for i, name in enumerate(names):
label = labels[i] if labels else None label = labels[i] if labels else None

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