Compare commits

...

87 Commits

Author SHA1 Message Date
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
86 changed files with 856 additions and 259 deletions

View File

@@ -11,7 +11,7 @@ about: Report a reproducible bug in the current release of NetBox
NetBox installation, or if you have a general question, DO NOT open an
issue. Instead, post to our mailing list:
https://groups.google.com/forum/#!forum/netbox-discuss
https://groups.google.com/g/netbox-discuss
Please describe the environment in which you are running NetBox. Be sure
that you are running an unmodified instance of the latest stable release

View File

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

View File

@@ -11,7 +11,7 @@ about: Propose a new NetBox feature or enhancement
If you have a general idea or question, please post to our mailing list
instead of opening an issue:
https://groups.google.com/forum/#!forum/netbox-discuss
https://groups.google.com/g/netbox-discuss
NOTE: Due to an excessive backlog of feature requests, we are not currently
accepting any proposals which significantly extend NetBox's feature scope.
@@ -21,8 +21,8 @@ about: Propose a new NetBox feature or enhancement
before submitting a bug report.
-->
### Environment
* Python version: <!-- Example: 3.6.9 -->
* NetBox version: <!-- Example: 2.7.3 -->
* Python version:
* NetBox version:
<!--
Describe in detail the new functionality you are proposing. Include any

View File

@@ -8,7 +8,7 @@ except to report bugs or request features.
We have established a Google Groups Mailing List for issues and general
discussion. This is the best forum for obtaining assistance with NetBox
installation. You can find us [here](https://groups.google.com/forum/#!forum/netbox-discuss).
installation. You can find us [here](https://groups.google.com/g/netbox-discuss).
### Slack
@@ -164,7 +164,7 @@ overlooked.
* Official channels for communication include:
* GitHub issues/pull requests
* The [netbox-discuss](https://groups.google.com/forum/#!forum/netbox-discuss) mailing list
* The [netbox-discuss](https://groups.google.com/g/netbox-discuss) mailing list
* The **#netbox** channel on [NetworkToCode Slack](https://networktocode.slack.com/)
* Maintainers with no substantial recorded activity in a 60-day period will be

View File

@@ -12,7 +12,7 @@ complete list of requirements, see `requirements.txt`. The code is available [on
The complete documentation for NetBox can be found at [Read the Docs](http://netbox.readthedocs.io/en/stable/).
Questions? Comments? Please subscribe to [the netbox-discuss mailing list](https://groups.google.com/forum/#!forum/netbox-discuss),
Questions? Comments? Please subscribe to [the netbox-discuss mailing list](https://groups.google.com/g/netbox-discuss),
or join us in the #netbox Slack channel on [NetworkToCode](https://networktocode.slack.com/)!
### Build Status
@@ -44,7 +44,7 @@ and run `upgrade.sh`.
Feature requests and bug reports must be submitted as GiHub issues. (Please be
sure to use the [appropriate template](https://github.com/netbox-community/netbox/issues/new/choose).)
For general discussion, please consider joining our [mailing list](https://groups.google.com/forum/#!forum/netbox-discuss).
For general discussion, please consider joining our [mailing list](https://groups.google.com/g/netbox-discuss).
If you are interested in contributing to the development of NetBox, please read
our [contributing guide](CONTRIBUTING.md) prior to beginning any work.

View File

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

View File

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

View File

@@ -101,11 +101,14 @@ Once you have created a report, it will appear in the reports list. Initially, r
## Running Reports
!!! note
To run a report, a user must be assigned the `extras.run_report` permission. This is achieved by assigning the user (or group) a permission on the Report object and specifying the `run` action in the admin UI as shown below.
![Adding the run action to a permission](../../media/admin_ui_run_permission.png)
### Via the Web UI
Reports can be run via the web UI by navigating to the report and clicking the "run report" button at top right. Note that a user must have permission to create ReportResults in order to run reports. (Permissions can be assigned through the admin UI.)
Once a report has been run, its associated results will be included in the report view.
Reports can be run via the web UI by navigating to the report and clicking the "run report" button at top right. Once a report has been run, its associated results will be included in the report view.
### Via the API

View File

@@ -491,6 +491,14 @@ The file path to the location where custom reports will be kept. By default, thi
---
## RQ_DEFAULT_TIMEOUT
Default: `300`
The maximum execution time of a background task (such as running a custom script), in seconds.
---
## SCRIPTS_ROOT
Default: `$INSTALL_ROOT/netbox/scripts/`

View File

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

View File

@@ -7,7 +7,7 @@ NetBox is maintained as a [GitHub project](https://github.com/netbox-community/n
Communication among developers should always occur via public channels:
* [GitHub issues](https://github.com/netbox-community/netbox/issues) - All feature requests, bug reports, and other substantial changes to the code base **must** be documented in an issue.
* [The mailing list](https://groups.google.com/forum/#!forum/netbox-discuss) - The preferred forum for general discussion and support issues. Ideal for shaping a feature request prior to submitting an issue.
* [The mailing list](https://groups.google.com/g/netbox-discuss) - The preferred forum for general discussion and support issues. Ideal for shaping a feature request prior to submitting an issue.
* [#netbox on NetworkToCode](http://slack.networktocode.com/) - Good for quick chats. Avoid any discussion that might need to be referenced later on, as the chat history is not retained long.
## Governance

View File

@@ -89,7 +89,3 @@ On the `develop` branch, update `VERSION` in `settings.py` to point to the next
```
VERSION = 'v2.3.5-dev'
```
### Announce the Release
Announce the release on the [mailing list](https://groups.google.com/forum/#!forum/netbox-discuss). Include a link to the release and the (HTML-formatted) release notes.

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

View File

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

View File

@@ -12,6 +12,9 @@ Plugins can do a lot, including:
However, keep in mind that each piece of functionality is entirely optional. For example, if your plugin merely adds a piece of middleware or an API endpoint for existing data, there's no need to define any new models.
!!! warning
While very powerful, the NetBox plugins API is necessarily limited in its scope. The plugins API is discussed here in its entirety: Any part of the NetBox code base not documented here is _not_ part of the supported plugins API, and should not be employed by a plugin. Internal elements of NetBox are subject to change at any time and without warning. Plugin authors are **strongly** encouraged to develop plugins using only the officially supported components discussed here and those provided by the underlying Django framework so as to avoid breaking changes in future releases.
## Initial Setup
## Plugin Structure
@@ -328,6 +331,9 @@ A `PluginMenuButton` has the following attributes:
* `color` - One of the choices provided by `ButtonColorChoices` (optional)
* `permissions` - A list of permissions required to display this button (optional)
!!! note
Any buttons associated within a menu item will be shown only if the user has permission to view the link, regardless of what permissions are set on the buttons.
## Extending Core Templates
Plugins can inject custom content into certain areas of the detail views of applicable models. This is accomplished by subclassing `PluginTemplateExtension`, designating a particular NetBox model, and defining the desired methods to render custom content. Four methods are available:

View File

@@ -1,5 +1,96 @@
# NetBox v2.9
## 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
@@ -96,6 +187,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 LDAP authentication, set `REMOTE_AUTH_BACKEND` to `'netbox.authentication.LDAPBackend'`. (LDAP configuration parameters in `ldap_config.py` remain unchanged.)
* `REMOTE_AUTH_DEFAULT_PERMISSIONS` now takes a dictionary rather than a list. This is a mapping of permission names to a dictionary of constraining attributes, or `None`. For example, `['dcim.add_site', 'dcim.change_site']` would become `{'dcim.add_site': None, 'dcim.change_site': None}`.
* Backward compatibility for the old `webhooks` Redis queue name has been dropped. Ensure that your `REDIS` configuration parameter specifies both the `tasks` and `caching` databases.
### REST API Changes

View File

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

View File

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

View File

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

View File

@@ -149,7 +149,7 @@ class RackElevationSVG:
unit_cursor = 0
for u in elevation:
o = other[unit_cursor]
if not u['device'] and o['device']:
if not u['device'] and o['device'] and o['device'].device_type.is_full_depth:
u['device'] = o['device']
u['height'] = 1
unit_cursor += u.get('height', 1)

View File

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

View File

@@ -1680,12 +1680,21 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
'region_id': '$region'
}
)
rack_group = DynamicModelChoiceField(
queryset=RackGroup.objects.all(),
required=False,
display_field='display_name',
query_params={
'site_id': '$site'
}
)
rack = DynamicModelChoiceField(
queryset=Rack.objects.all(),
required=False,
display_field='display_name',
query_params={
'site_id': '$site'
'site_id': '$site',
'group_id': '$rack_group',
}
)
position = forms.TypedChoiceField(
@@ -2317,7 +2326,7 @@ class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = ConsoleServerPort
fields = [
'device', 'name', 'type', 'description', 'tags',
'device', 'name', 'label', 'type', 'description', 'tags',
]
widgets = {
'device': forms.HiddenInput(),
@@ -2390,7 +2399,7 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = PowerPort
fields = [
'device', 'name', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags',
'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags',
]
widgets = {
'device': forms.HiddenInput(),
@@ -2479,7 +2488,7 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = PowerOutlet
fields = [
'device', 'name', 'type', 'power_port', 'feed_leg', 'description', 'tags',
'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'tags',
]
widgets = {
'device': forms.HiddenInput(),
@@ -2835,6 +2844,24 @@ class InterfaceBulkEditForm(
self.fields['untagged_vlan'].widget.add_query_param('site_id', device.site.pk)
self.fields['tagged_vlans'].widget.add_query_param('site_id', device.site.pk)
else:
# See 4523
if 'pk' in self.initial:
site = None
interfaces = Interface.objects.filter(pk__in=self.initial['pk']).prefetch_related('device__site')
# Check interface sites. First interface should set site, further interfaces will either continue the
# loop or reset back to no site and break the loop.
for interface in interfaces:
if site is None:
site = interface.device.site
elif interface.device.site is not site:
site = None
break
if site is not None:
self.fields['untagged_vlan'].widget.add_query_param('site_id', site.pk)
self.fields['tagged_vlans'].widget.add_query_param('site_id', site.pk)
self.fields['lag'].choices = ()
self.fields['lag'].widget.attrs['disabled'] = True

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(
default=1,
validators=[MinValueValidator(1), MaxValueValidator(64)]
validators=[
MinValueValidator(REARPORT_POSITIONS_MIN),
MaxValueValidator(REARPORT_POSITIONS_MAX)
]
)
class Meta:
@@ -315,7 +318,10 @@ class RearPortTemplate(ComponentTemplateModel):
)
positions = models.PositiveSmallIntegerField(
default=1,
validators=[MinValueValidator(1), MaxValueValidator(64)]
validators=[
MinValueValidator(REARPORT_POSITIONS_MIN),
MaxValueValidator(REARPORT_POSITIONS_MAX)
]
)
class Meta:

View File

@@ -405,6 +405,14 @@ class PowerPort(CableTermination, ComponentModel):
self.description,
)
def clean(self):
if self.maximum_draw is not None and self.allocated_draw is not None:
if self.allocated_draw > self.maximum_draw:
raise ValidationError({
'allocated_draw': f"Allocated draw cannot exceed the maximum draw ({self.maximum_draw}W)."
})
@property
def connected_endpoint(self):
"""
@@ -809,7 +817,10 @@ class FrontPort(CableTermination, ComponentModel):
)
rear_port_position = models.PositiveSmallIntegerField(
default=1,
validators=[MinValueValidator(1), MaxValueValidator(64)]
validators=[
MinValueValidator(REARPORT_POSITIONS_MIN),
MaxValueValidator(REARPORT_POSITIONS_MAX)
]
)
tags = TaggableManager(through=TaggedItem)
@@ -840,17 +851,16 @@ class FrontPort(CableTermination, ComponentModel):
# Validate rear port assignment
if self.rear_port.device != self.device:
raise ValidationError(
"Rear port ({}) must belong to the same device".format(self.rear_port)
)
raise ValidationError({
"rear_port": f"Rear port ({self.rear_port}) must belong to the same device"
})
# Validate rear port position assignment
if self.rear_port_position > self.rear_port.positions:
raise ValidationError(
"Invalid rear port position ({}); rear port {} has only {} positions".format(
self.rear_port_position, self.rear_port.name, self.rear_port.positions
)
)
raise ValidationError({
"rear_port_position": f"Invalid rear port position ({self.rear_port_position}): Rear port "
f"{self.rear_port.name} has only {self.rear_port.positions} positions"
})
@extras_features('webhooks')
@@ -864,7 +874,10 @@ class RearPort(CableTermination, ComponentModel):
)
positions = models.PositiveSmallIntegerField(
default=1,
validators=[MinValueValidator(1), MaxValueValidator(64)]
validators=[
MinValueValidator(REARPORT_POSITIONS_MIN),
MaxValueValidator(REARPORT_POSITIONS_MAX)
]
)
tags = TaggableManager(through=TaggedItem)
@@ -877,6 +890,16 @@ class RearPort(CableTermination, ComponentModel):
def get_absolute_url(self):
return reverse('dcim:rearport', kwargs={'pk': self.pk})
def clean(self):
# Check that positions count is greater than or equal to the number of associated FrontPorts
frontport_count = self.frontports.count()
if self.positions < frontport_count:
raise ValidationError({
"positions": f"The number of positions cannot be less than the number of mapped front ports "
f"({frontport_count})"
})
def to_csv(self):
return (
self.device.identifier,

View File

@@ -152,6 +152,10 @@ INTERFACE_TAGGED_VLANS = """
{% endfor %}
"""
CONNECTION_STATUS = """
<span class="label label-{% if record.connection_status %}success{% else %}danger{% endif %}">{{ record.get_connection_status_display }}</span>
"""
#
# Regions
@@ -908,15 +912,20 @@ class ConsoleConnectionTable(BaseTable):
verbose_name='Console Server'
)
connected_endpoint = tables.Column(
linkify=True,
verbose_name='Port'
)
device = tables.Column(
linkify=True
)
name = tables.Column(
linkify=True,
verbose_name='Console Port'
)
connection_status = BooleanColumn()
connection_status = tables.TemplateColumn(
template_code=CONNECTION_STATUS,
verbose_name='Status'
)
class Meta(BaseTable.Meta):
model = ConsolePort
@@ -933,14 +942,20 @@ class PowerConnectionTable(BaseTable):
)
outlet = tables.Column(
accessor=Accessor('_connected_poweroutlet'),
linkify=True,
verbose_name='Outlet'
)
device = tables.Column(
linkify=True
)
name = tables.Column(
linkify=True,
verbose_name='Power Port'
)
connection_status = tables.TemplateColumn(
template_code=CONNECTION_STATUS,
verbose_name='Status'
)
class Meta(BaseTable.Meta):
model = PowerPort
@@ -972,6 +987,10 @@ class InterfaceConnectionTable(BaseTable):
args=[Accessor('_connected_interface__pk')],
verbose_name='Interface B'
)
connection_status = tables.TemplateColumn(
template_code=CONNECTION_STATUS,
verbose_name='Status'
)
class Meta(BaseTable.Meta):
model = Interface

View File

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

View File

@@ -158,7 +158,7 @@ class CustomFieldModelSerializer(ValidatedModelSerializer):
instance.custom_fields = {}
for field in custom_fields:
value = instance.cf.get(field.name)
if field.type == CustomFieldTypeChoices.TYPE_SELECT and value is not None:
if field.type == CustomFieldTypeChoices.TYPE_SELECT and type(value) is CustomFieldChoice:
instance.custom_fields[field.name] = CustomFieldChoiceSerializer(value).data
else:
instance.custom_fields[field.name] = value

View File

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

View File

@@ -140,6 +140,7 @@ class ImageAttachmentViewSet(ModelViewSet):
metadata_class = ContentTypeMetadata
queryset = ImageAttachment.objects.all()
serializer_class = serializers.ImageAttachmentSerializer
filterset_class = filters.ImageAttachmentFilterSet
#

View File

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

View File

@@ -397,10 +397,11 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
required=False,
widget=StaticSelect2()
)
user = DynamicModelMultipleChoiceField(
user_id = DynamicModelMultipleChoiceField(
queryset=User.objects.all(),
required=False,
display_field='username',
label='User',
widget=APISelectMultiple(
api_url='/api/users/users/',
)

View File

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

View File

@@ -200,15 +200,14 @@ class CustomField(models.Model):
# Select
elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
choices = [(cfc.pk, cfc.value) for cfc in self.choices.all()]
default_choice = self.choices.filter(value=self.default).first()
if not required:
if not required or default_choice is None:
choices = add_blank_choice(choices)
# Set the initial value to the PK of the default choice, if any
if set_initial:
default_choice = self.choices.filter(value=self.default).first()
if default_choice:
initial = default_choice.pk
if set_initial and default_choice:
initial = default_choice.pk
field_class = CSVChoiceField if for_csv_import else forms.ChoiceField
field = field_class(

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
from django import template
from django.conf import settings
from django.contrib import messages
from django.contrib.contenttypes.models import ContentType
from django.db.models import Count, Prefetch, Q
@@ -13,7 +12,7 @@ from rq import Worker
from dcim.models import DeviceRole, Platform, Region, Site
from tenancy.models import Tenant, TenantGroup
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator
from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.utils import copy_safe_request, shallow_compare_dict
from utilities.views import (
BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
@@ -258,7 +257,7 @@ class ObjectChangeLogView(View):
# Apply the request context
paginate = {
'paginator_class': EnhancedPaginator,
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
'per_page': get_paginate_count(request)
}
RequestConfig(request, paginate).configure(objectchanges_table)
@@ -315,7 +314,7 @@ class ReportListView(ContentTypePermissionRequiredMixin, View):
Retrieve all of the available reports from disk and the recorded JobResult (if any) for each.
"""
def get_required_permission(self):
return 'extras.view_reportresult'
return 'extras.view_report'
def get(self, request):
@@ -347,7 +346,7 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
Display a single Report and its associated JobResult (if any).
"""
def get_required_permission(self):
return 'extras.view_reportresult'
return 'extras.view_report'
def get(self, request, module, name):

View File

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

View File

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

View File

@@ -726,30 +726,18 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
})
# Check for primary IP assignment that doesn't match the assigned device/VM
if self.pk and type(self.assigned_object) is Interface:
if self.pk:
device = Device.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first()
if device:
if self.assigned_object is None:
if getattr(self.assigned_object, 'device', None) != device:
raise ValidationError({
'interface': f"IP address is primary for device {device} but not assigned to an interface"
'interface': f"IP address is primary for device {device} but not assigned to it!"
})
elif self.assigned_object.device != device:
raise ValidationError({
'interface': f"IP address is primary for device {device} but assigned to "
f"{self.assigned_object.device} ({self.assigned_object})"
})
elif self.pk and type(self.assigned_object) is VMInterface:
vm = VirtualMachine.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first()
if vm:
if self.assigned_object is None:
if getattr(self.assigned_object, 'virtual_machine', None) != vm:
raise ValidationError({
'vminterface': f"IP address is primary for virtual machine {vm} but not assigned to an "
f"interface"
})
elif self.assigned_object.virtual_machine != vm:
raise ValidationError({
'vminterface': f"IP address is primary for virtual machine {vm} but assigned to "
f"{self.assigned_object.virtual_machine} ({self.assigned_object})"
'vminterface': f"IP address is primary for virtual machine {vm} but not assigned to it!"
})
# Validate IP status selection
@@ -997,13 +985,20 @@ class VLAN(ChangeLoggedModel, CustomFieldModel):
def get_status_class(self):
return self.STATUS_CLASS_MAP[self.status]
def get_members(self):
# Return all interfaces assigned to this VLAN
def get_interfaces(self):
# Return all device interfaces assigned to this VLAN
return Interface.objects.filter(
Q(untagged_vlan_id=self.pk) |
Q(tagged_vlans=self.pk)
).distinct()
def get_vminterfaces(self):
# Return all VM interfaces assigned to this VLAN
return VMInterface.objects.filter(
Q(untagged_vlan_id=self.pk) |
Q(tagged_vlans=self.pk)
).distinct()
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class Service(ChangeLoggedModel, CustomFieldModel):

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ from django.shortcuts import get_object_or_404, redirect, render
from django_tables2 import RequestConfig
from dcim.models import Device, Interface
from utilities.paginator import EnhancedPaginator
from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.utils import get_subquery
from utilities.views import (
BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView,
@@ -233,7 +233,7 @@ class AggregateView(ObjectView):
paginate = {
'paginator_class': EnhancedPaginator,
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
'per_page': get_paginate_count(request)
}
RequestConfig(request, paginate).configure(prefix_table)
@@ -391,7 +391,7 @@ class PrefixPrefixesView(ObjectView):
paginate = {
'paginator_class': EnhancedPaginator,
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
'per_page': get_paginate_count(request)
}
RequestConfig(request, paginate).configure(prefix_table)
@@ -435,7 +435,7 @@ class PrefixIPAddressesView(ObjectView):
paginate = {
'paginator_class': EnhancedPaginator,
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
'per_page': get_paginate_count(request)
}
RequestConfig(request, paginate).configure(ip_table)
@@ -527,7 +527,8 @@ class IPAddressView(ObjectView):
# Exclude anycast IPs if this IP is anycast
if ipaddress.role == IPAddressRoleChoices.ROLE_ANYCAST:
duplicate_ips = duplicate_ips.exclude(role=IPAddressRoleChoices.ROLE_ANYCAST)
duplicate_ips_table = tables.IPAddressTable(list(duplicate_ips), orderable=False)
# Limit to a maximum of 10 duplicates displayed here
duplicate_ips_table = tables.IPAddressTable(duplicate_ips[:10], orderable=False)
# Related IP table
related_ips = IPAddress.objects.restrict(request.user, 'view').exclude(
@@ -539,7 +540,7 @@ class IPAddressView(ObjectView):
paginate = {
'paginator_class': EnhancedPaginator,
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
'per_page': get_paginate_count(request)
}
RequestConfig(request, paginate).configure(related_ips_table)
@@ -547,6 +548,7 @@ class IPAddressView(ObjectView):
'ipaddress': ipaddress,
'parent_prefixes_table': parent_prefixes_table,
'duplicate_ips_table': duplicate_ips_table,
'more_duplicate_ips': duplicate_ips.count() > 10,
'related_ips_table': related_ips_table,
})
@@ -582,7 +584,7 @@ class IPAddressAssignView(ObjectView):
def dispatch(self, request, *args, **kwargs):
# Redirect user if an interface has not been provided
if 'interface' not in request.GET:
if 'interface' not in request.GET and 'vminterface' not in request.GET:
return redirect('ipam:ipaddress_add')
return super().dispatch(request, *args, **kwargs)
@@ -609,7 +611,7 @@ class IPAddressAssignView(ObjectView):
return render(request, 'ipam/ipaddress_assign.html', {
'form': form,
'table': table,
'return_url': request.GET.get('return_url', ''),
'return_url': request.GET.get('return_url'),
})
@@ -699,7 +701,7 @@ class VLANGroupVLANsView(ObjectView):
paginate = {
'paginator_class': EnhancedPaginator,
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
'per_page': get_paginate_count(request),
}
RequestConfig(request, paginate).configure(vlan_table)
@@ -713,6 +715,7 @@ class VLANGroupVLANsView(ObjectView):
return render(request, 'ipam/vlangroup_vlans.html', {
'vlan_group': vlan_group,
'first_available_vlan': vlan_group.get_next_available_vid(),
'bulk_querystring': 'group_id={}'.format(vlan_group.pk),
'vlan_table': vlan_table,
'permissions': permissions,
})
@@ -749,26 +752,45 @@ class VLANView(ObjectView):
})
class VLANMembersView(ObjectView):
class VLANInterfacesView(ObjectView):
queryset = VLAN.objects.all()
def get(self, request, pk):
vlan = get_object_or_404(self.queryset, pk=pk)
members = vlan.get_members().restrict(request.user, 'view').prefetch_related('device', 'virtual_machine')
members_table = tables.VLANMemberTable(members)
interfaces = vlan.get_interfaces().prefetch_related('device')
members_table = tables.VLANDevicesTable(interfaces)
paginate = {
'paginator_class': EnhancedPaginator,
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
'per_page': get_paginate_count(request)
}
RequestConfig(request, paginate).configure(members_table)
return render(request, 'ipam/vlan_members.html', {
return render(request, 'ipam/vlan_interfaces.html', {
'vlan': vlan,
'members_table': members_table,
'active_tab': 'members',
'active_tab': 'interfaces',
})
class VLANVMInterfacesView(ObjectView):
queryset = VLAN.objects.all()
def get(self, request, pk):
vlan = get_object_or_404(self.queryset, pk=pk)
interfaces = vlan.get_vminterfaces().prefetch_related('virtual_machine')
members_table = tables.VLANVirtualMachinesTable(interfaces)
paginate = {
'paginator_class': EnhancedPaginator,
'per_page': get_paginate_count(request)
}
RequestConfig(request, paginate).configure(members_table)
return render(request, 'ipam/vlan_vminterfaces.html', {
'vlan': vlan,
'members_table': members_table,
'active_tab': 'vminterfaces',
})

View File

@@ -175,6 +175,6 @@ class LDAPBackend:
# Enable logging for django_auth_ldap
ldap_logger = logging.getLogger('django_auth_ldap')
ldap_logger.addHandler(logging.StreamHandler())
ldap_logger.setLevel(logging.DEBUG)
ldap_logger.setLevel(logging.INFO)
return obj

View File

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

View File

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

View File

@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
# Environment setup
#
VERSION = '2.9.2'
VERSION = '2.9.6'
# Hostname
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_TIMEOUT = getattr(configuration, 'RELEASE_CHECK_TIMEOUT', 24 * 3600)
REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None)
SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d')
@@ -220,10 +221,13 @@ TASKS_REDIS_USING_SENTINEL = all([
len(TASKS_REDIS_SENTINELS) > 0
])
TASKS_REDIS_SENTINEL_SERVICE = TASKS_REDIS.get('SENTINEL_SERVICE', 'default')
TASKS_REDIS_SENTINEL_TIMEOUT = TASKS_REDIS.get('SENTINEL_TIMEOUT', 10)
TASKS_REDIS_PASSWORD = TASKS_REDIS.get('PASSWORD', '')
TASKS_REDIS_DATABASE = TASKS_REDIS.get('DATABASE', 0)
TASKS_REDIS_DEFAULT_TIMEOUT = TASKS_REDIS.get('DEFAULT_TIMEOUT', 300)
TASKS_REDIS_SSL = TASKS_REDIS.get('SSL', False)
# TODO: Remove in v2.10 (see #5171)
if 'DEFAULT_TIMEOUT' in TASKS_REDIS:
warnings.warn('DEFAULT_TIMEOUT is no longer supported under REDIS configuration. Set RQ_DEFAULT_TIMEOUT instead.')
# Caching
if 'caching' not in REDIS:
@@ -241,7 +245,6 @@ CACHING_REDIS_USING_SENTINEL = all([
CACHING_REDIS_SENTINEL_SERVICE = CACHING_REDIS.get('SENTINEL_SERVICE', 'default')
CACHING_REDIS_PASSWORD = CACHING_REDIS.get('PASSWORD', '')
CACHING_REDIS_DATABASE = CACHING_REDIS.get('DATABASE', 0)
CACHING_REDIS_DEFAULT_TIMEOUT = CACHING_REDIS.get('DEFAULT_TIMEOUT', 300)
CACHING_REDIS_SSL = CACHING_REDIS.get('SSL', False)
@@ -549,7 +552,7 @@ if TASKS_REDIS_USING_SENTINEL:
'PASSWORD': TASKS_REDIS_PASSWORD,
'SOCKET_TIMEOUT': None,
'CONNECTION_KWARGS': {
'socket_connect_timeout': TASKS_REDIS_DEFAULT_TIMEOUT
'socket_connect_timeout': TASKS_REDIS_SENTINEL_TIMEOUT
},
}
else:
@@ -558,8 +561,8 @@ else:
'PORT': TASKS_REDIS_PORT,
'DB': TASKS_REDIS_DATABASE,
'PASSWORD': TASKS_REDIS_PASSWORD,
'DEFAULT_TIMEOUT': TASKS_REDIS_DEFAULT_TIMEOUT,
'SSL': TASKS_REDIS_SSL,
'DEFAULT_TIMEOUT': RQ_DEFAULT_TIMEOUT,
}
RQ_QUEUES = {

View File

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

View File

@@ -11,11 +11,8 @@
<div class="row noprint">
<div class="col-sm-8 col-md-9">
<ol class="breadcrumb">
<li><a href="{% url 'dcim:site' slug=device.site.slug %}">{{ device.site }}</a></li>
{% if device.rack %}
<li><a href="{% url 'dcim:rack_list' %}?site={{ device.site.slug }}">Racks</a></li>
<li><a href="{% url 'dcim:rack' pk=device.rack.pk %}">{{ device.rack }}</a></li>
{% endif %}
<li><a href="{% url 'dcim:device_list' %}">Devices</a></li>
<li><a href="{% url 'dcim:device_list' %}?site={{ device.site.slug }}">{{ device.site }}</a></li>
{% if device.parent_bay %}
<li><a href="{% url 'dcim:device' pk=device.parent_bay.device.pk %}">{{ device.parent_bay.device }}</a></li>
<li>{{ device.parent_bay }}</li>
@@ -101,7 +98,7 @@
</li>
<li role="presentation"{% if active_tab == 'inventory' %} class="active"{% endif %}>
<a href="{% url 'dcim:device_inventory' pk=device.pk %}">
Inventory <span class="badge">{{ device.inventoryitems.unrestricted.count }}</span>
Inventory <span class="badge">{{ device.inventoryitems.count }}</span>
</a>
</li>
{% if perms.dcim.napalm_read_device %}
@@ -151,8 +148,10 @@
<td>
{% if device.rack %}
{% if device.rack.group %}
<a href="{{ device.rack.group.get_absolute_url }}">{{ device.rack.group }}</a>
<i class="fa fa-angle-right"></i>
{% for group in device.rack.group.get_ancestors %}
<a href="{{ group.get_absolute_url }}">{{ group }}</a> <i class="fa fa-caret-right"></i>
{% endfor %}
<a href="{{ device.rack.group.get_absolute_url }}">{{ device.rack.group }}</a> <i class="fa fa-caret-right"></i>
{% endif %}
<a href="{% url 'dcim:rack' pk=device.rack.pk %}">{{ device.rack }}</a>
{% else %}
@@ -327,7 +326,7 @@
{% plugin_left_page device %}
</div>
<div class="col-md-6">
{% if power_ports and poweroutlets %}
{% if powerports and poweroutlets %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>Power Utilization</strong>
@@ -340,7 +339,7 @@
<th>Available</th>
<th>Utilization</th>
</tr>
{% for pp in power_ports %}
{% for pp in powerports %}
{% with utilization=pp.get_power_draw powerfeed=pp.connected_endpoint %}
<tr>
<td>{{ pp }}</td>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -78,7 +78,7 @@
</span>
{% endif %}
{% 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>
</a>
{% 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-heading"><strong>Interface</strong></div>
<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.label %}
{% render_field form.type %}
@@ -14,6 +24,11 @@
{% render_field form.mtu %}
{% render_field form.mgmt_only %}
{% 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.untagged_vlan %}
{% render_field form.tagged_vlans %}

View File

@@ -11,6 +11,12 @@
<ol class="breadcrumb">
<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>
{% 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>
</ol>
</div>
@@ -87,7 +93,10 @@
<td>Group</td>
<td>
{% 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 %}
<span class="text-muted">None</span>
{% endif %}

View File

@@ -3,12 +3,18 @@
{% load static %}
{% 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">
<span class="glyphicon glyphicon-check" aria-hidden="true"></span> Show Images
</button>
<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>
<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='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>
<h1>{% block title %}Rack Elevations{% endblock %}</h1>
<div class="row">
@@ -17,10 +23,23 @@
<div style="white-space: nowrap; overflow-x: scroll;">
{% for rack in page %}
<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 %}
<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>
{% endfor %}
</div>

View File

@@ -12,7 +12,7 @@
<ol class="breadcrumb">
<li><a href="{% url 'dcim:site_list' %}">Sites</a></li>
{% 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>
{% endfor %}
<li><a href="{{ site.region.get_absolute_url }}">{{ site.region }}</a></li>
@@ -86,7 +86,7 @@
<td>Region</td>
<td>
{% 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>
<i class="fa fa-angle-right"></i>
{% endfor %}
@@ -255,7 +255,7 @@
<table class="table table-hover panel-body">
{% for rg in rack_groups %}
<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 class="text-right noprint">
<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:
</p>
<p>
<i class="fa fa-warning"></i> <strong>Missing required packages</strong> - This installation of NetBox might be missing one or more required
Python packages. These packages are listed in <code>requirements.txt</code> and are normally installed as part
of the installation or upgrade process. To verify installed packages, run <code>pip freeze</code> from the
console and compare the output to the list of required packages.
<i class="fa fa-warning"></i> <strong>Missing required packages</strong> - This installation of NetBox might be
missing one or more required Python packages. These packages are listed in <code>requirements.txt</code> and
<code>local_requirements.txt</code>, and are normally installed as part of the installation or upgrade process.
To verify installed packages, run <code>pip freeze</code> from the console and compare the output to the list of
required packages.
</p>
<p>
<i class="fa fa-warning"></i> <strong>WSGI service not restarted after upgrade</strong> - If this installation has recently been upgraded,
check that the WSGI service (e.g. gunicorn or uWSGI) has been restarted. This ensures that the new code is
running.
<i class="fa fa-warning"></i> <strong>WSGI service not restarted after upgrade</strong> - If this installation
has recently been upgraded, check that the WSGI service (e.g. gunicorn or uWSGI) has been restarted. This
ensures that the new code is running.
</p>
{% endblock %}

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@
{% load custom_links %}
{% load helpers %}
{% load plugins %}
{% load render_table from django_tables2 %}
{% block header %}
<div class="row noprint">
@@ -159,7 +160,24 @@
<div class="col-md-8">
{% include 'panel_table.html' with table=parent_prefixes_table heading='Parent Prefixes' %}
{% 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 %}
{% 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 %}

View File

@@ -52,8 +52,11 @@
<li role="presentation"{% if not active_tab %} class="active"{% endif %}>
<a href="{% url 'ipam:vlan' pk=vlan.pk %}">VLAN</a>
</li>
<li role="presentation"{% if active_tab == 'members' %} class="active"{% endif %}>
<a href="{% url 'ipam:vlan_members' pk=vlan.pk %}">Members <span class="badge">{{ vlan.get_members.count }}</span></a>
<li role="presentation"{% if active_tab == 'interfaces' %} class="active"{% endif %}>
<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>
{% if perms.extras.view_objectchange %}
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>

View File

@@ -1,11 +1,9 @@
{% extends 'ipam/vlan.html' %}
{% block title %}{{ block.super }} - Members{% endblock %}
{% 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='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>
{% 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-heading"><strong>{{ obj_type|capfirst }}</strong></div>
<div class="panel-body">
{% render_form form %}
{% block form_fields %}
{% render_form form %}
{% endblock %}
</div>
</div>
{% endblock %}

View File

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

View File

@@ -5,14 +5,34 @@
<div class="panel panel-default">
<div class="panel-heading"><strong>Interface</strong></div>
<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.enabled %}
{% render_field form.mac_address %}
{% render_field form.mtu %}
{% 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.untagged_vlan %}
{% 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 %}
</div>
</div>

View File

@@ -38,6 +38,10 @@ class LoginView(View):
def get(self, 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, {
'form': form,
})
@@ -49,12 +53,6 @@ class LoginView(View):
if form.is_valid():
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
# last_login time upon authentication.
if settings.MAINTENANCE_MODE:
@@ -66,8 +64,7 @@ class LoginView(View):
logger.info(f"User {request.user} successfully authenticated")
messages.info(request, "Logged in as {}.".format(request.user))
logger.debug(f"Redirecting user to {redirect_to}")
return HttpResponseRedirect(redirect_to)
return self.redirect_to_next(request, logger)
else:
logger.debug("Login form validation failed")
@@ -76,6 +73,19 @@ class LoginView(View):
'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):
"""

View File

@@ -141,7 +141,7 @@ class APISelect(SelectWithDisabled):
key = f'data-query-param-{name}'
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])
else:
values.append(str(value))

View File

@@ -22,7 +22,7 @@ class Command(_Command):
"This command is available for development purposes only. It will\n"
"NOT resolve any issues with missing or unapplied migrations. For assistance,\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)

View File

@@ -1,4 +1,5 @@
import django_tables2 as tables
from django.contrib.contenttypes.fields import GenericForeignKey
from django.core.exceptions import FieldDoesNotExist
from django.db.models.fields.related import RelatedField
from django.utils.safestring import mark_safe
@@ -44,7 +45,7 @@ class BaseTable(tables.Table):
self.columns.show(name)
else:
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
if pk:
@@ -63,7 +64,7 @@ class BaseTable(tables.Table):
field_path = column.accessor.split('.')
try:
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))
except FieldDoesNotExist:
pass
@@ -114,12 +115,12 @@ class BooleanColumn(tables.Column):
character.
"""
def render(self, value):
if value is True:
if value:
rendered = '<span class="text-success"><i class="fa fa-check"></i></span>'
elif value is False:
rendered = '<span class="text-danger"><i class="fa fa-close"></i></span>'
else:
elif value is None:
rendered = '<span class="text-muted">&mdash;</span>'
else:
rendered = '<span class="text-danger"><i class="fa fa-close"></i></span>'
return mark_safe(rendered)

View File

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

View File

@@ -945,7 +945,7 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
# ManyToManyFields
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])
# Normal fields
elif form.cleaned_data[name] not in (None, ''):
@@ -1352,7 +1352,7 @@ class BulkComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin,
for obj in data['pk']:
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):
label = labels[i] if labels else None

View File

@@ -186,6 +186,10 @@ class VirtualMachineFilterSet(
field_name='interfaces__mac_address',
label='MAC address',
)
has_primary_ip = django_filters.BooleanFilter(
method='_has_primary_ip',
label='Has a primary IP',
)
tag = TagFilter()
class Meta:
@@ -200,6 +204,12 @@ class VirtualMachineFilterSet(
Q(comments__icontains=value)
)
def _has_primary_ip(self, queryset, name, value):
params = Q(primary_ip4__isnull=False) | Q(primary_ip6__isnull=False)
if value:
return queryset.filter(params)
return queryset.exclude(params)
class VMInterfaceFilterSet(BaseFilterSet):
q = django_filters.CharFilter(

View File

@@ -516,6 +516,13 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil
required=False,
label='MAC address'
)
has_primary_ip = forms.NullBooleanField(
required=False,
label='Has a primary IP',
widget=StaticSelect2(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
tag = TagFilterField(model)

View File

@@ -83,6 +83,7 @@ def replicate_interfaces(apps, schema_editor):
class Migration(migrations.Migration):
dependencies = [
('dcim', '0082_3569_interface_fields'),
('ipam', '0037_ipaddress_assignment'),
('virtualization', '0015_vminterface'),
]

View File

@@ -1,6 +1,7 @@
from django.test import TestCase
from dcim.models import DeviceRole, Platform, Region, Site
from ipam.models import IPAddress
from tenancy.models import Tenant, TenantGroup
from virtualization.choices import *
from virtualization.filters import *
@@ -266,6 +267,15 @@ class VirtualMachineTestCase(TestCase):
)
VMInterface.objects.bulk_create(interfaces)
# Assign primary IPs for filtering
ipaddresses = (
IPAddress(address='192.0.2.1/24', assigned_object=interfaces[0]),
IPAddress(address='192.0.2.2/24', assigned_object=interfaces[1]),
)
IPAddress.objects.bulk_create(ipaddresses)
VirtualMachine.objects.filter(pk=vms[0].pk).update(primary_ip4=ipaddresses[0])
VirtualMachine.objects.filter(pk=vms[1].pk).update(primary_ip4=ipaddresses[1])
def test_id(self):
params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -344,6 +354,12 @@ class VirtualMachineTestCase(TestCase):
params = {'mac_address': ['00-00-00-00-00-01', '00-00-00-00-00-02']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_has_primary_ip(self):
params = {'has_primary_ip': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'has_primary_ip': 'false'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_local_context_data(self):
params = {'local_context_data': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)

View File

@@ -13,6 +13,15 @@ EXIT=0
RED='\033[0;31m'
NOCOLOR='\033[0m'
if [ -d ./venv/ ]; then
VENV="$PWD/venv"
if [ -e $VENV/bin/python ]; then
PATH=$VENV/bin:$PATH
elif [ -e $VENV/Scripts/python.exe ]; then
PATH=$VENV/Scripts:$PATH
fi
fi
echo "Validating PEP8 compliance..."
pycodestyle --ignore=W504,E501 netbox/
if [ $? != 0 ]; then